mirror of
https://github.com/mntno/obsidian-come-down.git
synced 2026-07-22 05:43:15 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d384fb2429 | ||
|
|
a424f3e48c | ||
|
|
fa5323f538 | ||
|
|
66272ab777 | ||
|
|
9fc5f21f13 | ||
|
|
10fe4932f7 |
39 changed files with 5522 additions and 1862 deletions
20
.gemini/settings.json
Normal file
20
.gemini/settings.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"tools": {
|
||||
"autoAccept": false
|
||||
},
|
||||
"context": {
|
||||
"fileName": [
|
||||
"AGENTS.md"
|
||||
],
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": true
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"hideBanner": true
|
||||
},
|
||||
"privacy": {
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
}
|
||||
52
.gitignore
vendored
52
.gitignore
vendored
|
|
@ -1,26 +1,26 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Hot Reload plugin
|
||||
.hotreload
|
||||
|
||||
# This plugin
|
||||
|
||||
## The compiled main.js file.
|
||||
main.js
|
||||
|
||||
## Created by plugin.
|
||||
cache.json
|
||||
cache/
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# This plugin
|
||||
|
||||
## Files are copied here on `pnpm run build`.
|
||||
/dist/
|
||||
|
||||
## Sym-linked to the plugin's folder in the dev/test vault. Files are copied here `pnpm run dev`.
|
||||
## Git stores symlinks as a special file containing the target path string, not as a directory. So the gitignore rule needs to match a file, not a directory pattern.
|
||||
dev-vault
|
||||
|
||||
main.js
|
||||
cache.json
|
||||
|
|
|
|||
7
.zed/settings.json
Normal file
7
.zed/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{
|
||||
"project_name": "Come Down"
|
||||
}
|
||||
139
AGENTS.md
Normal file
139
AGENTS.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: pnpm** (required for this sample - `package.json` defines pnpm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on pnpm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
Use this when verifying changes.
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `pnpm run build` or `pnpm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
21
README.md
21
README.md
|
|
@ -27,7 +27,7 @@ Having the cache located inside the plugin’s folder serves several purposes:
|
|||
- It allows the cache to be automatically deleted if the plugin is uninstalled.
|
||||
- A .gitignore file can be placed in the cache folder to exclude it from Git. Had it been in a visible folder, users might mistakenly think that files they add there would be committed.
|
||||
|
||||
### Embedded Images Are Already Cached
|
||||
### Embedded Images are Already Cached
|
||||
|
||||
Obsidian is built on Electron, which embeds the Chromium web browser. Therefore, it has built-in caching just as any web browser. But this cache exists outside your vault.
|
||||
|
||||
|
|
@ -35,19 +35,12 @@ For example, if you copied your vault's root folder to a USB memory and opened i
|
|||
|
||||
### Disabling
|
||||
|
||||
If the plugin is disabled, everything will work as it did before the plugin was enabled — the embedded images will load by means of the underlying browser. Upon enabling it again, the plugin's already cached items will be used, bypassing the browser.
|
||||
If the plugin is disabled, everything will work as it did before the plugin was enabled — the embedded images will load by means of the underlying browser. Upon enabling it again, the plugin's already cached items will be used, bypassing the browser.
|
||||
|
||||
Note that if an embedded image is removed from a note while the plugin is disabled, its potential cached file will not be removed until that specific note is opened again with the plugin enabled. If you want to disable the plugin you may delete the cache first from settings.
|
||||
Note that if an embedded image is removed from a note while the plugin is disabled, its potential cached file will not be removed until that specific note is opened again with the plugin enabled. If you want to disable the plugin, you may want to delete the cache from the settings first.
|
||||
|
||||
### Syncing
|
||||
|
||||
It is best to avoid caching on several devices without synchronizing in between, as this can lead to synchronization conflicts and inconsistencies. For example, you may see the same image download again on another device.
|
||||
|
||||
[^1]: This excludes [Obsidian Sync](https://obsidian.md/sync).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
|
|
@ -10,6 +12,10 @@ if you want to view the source, please visit the github repository of this plugi
|
|||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
const outdir = prod ? "dist" : "dev-vault";
|
||||
|
||||
fs.copyFileSync("manifest.json", path.join(outdir, "manifest.json"));
|
||||
fs.copyFileSync("styles.css", path.join(outdir, "styles.css"));
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
|
|
@ -33,15 +39,16 @@ const context = await esbuild.context({
|
|||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2022",
|
||||
// Runtime of min supported Obsidian version 1.8.0, see manifest.json
|
||||
target: "es2024",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
outdir: outdir,
|
||||
minify: prod,
|
||||
define: {
|
||||
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
|
||||
},
|
||||
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
|
||||
},
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
|
|
|
|||
78
eslint.config.js
Normal file
78
eslint.config.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// https://typescript-eslint.io/packages/typescript-eslint#usage
|
||||
import eslint from "@eslint/js";
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from "typescript-eslint";
|
||||
import globals from "globals";
|
||||
|
||||
//import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
// If using Svelte
|
||||
// import sveltePlugin from "eslint-plugin-svelte";
|
||||
// import svelteParser from "svelte-eslint-parser";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"./main.js",
|
||||
"./src/**/*js",
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
// https://typescript-eslint.io/users/configs#recommended-configurations
|
||||
...tseslint.configs.recommended,
|
||||
//...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
// https://typescript-eslint.io/packages/typescript-eslint#manual-usage
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
sourceType: "module",
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
|
||||
// Instead of adding `obsidian-typings` package. Add whatever is used here instead.
|
||||
//createDiv: "readonly",
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// You should always have "no-unused-vars": "off" alongside @typescript-eslint/no-unused-vars,
|
||||
// https://typescript-eslint.io/rules/no-unused-vars/
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"args": "all",
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "all",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true,
|
||||
}],
|
||||
|
||||
//
|
||||
"@typescript-eslint/ban-ts-comment": ["error", {
|
||||
"ts-expect-error": false,
|
||||
"ts-ignore": true,
|
||||
"ts-nocheck": true,
|
||||
"ts-check": true,
|
||||
}],
|
||||
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": ["warn", {
|
||||
// https://typescript-eslint.io/rules/no-unnecessary-condition/#only-allowed-literals
|
||||
"allowConstantLoopConditions": "only-allowed-literals"
|
||||
}],
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
import typeScriptPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import typeScriptParser from "@typescript-eslint/parser";
|
||||
|
||||
// If using Svelte
|
||||
// import sveltePlugin from "eslint-plugin-svelte";
|
||||
// import svelteParser from "svelte-eslint-parser";
|
||||
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
"@typescript-eslint": typeScriptPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: typeScriptParser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
//...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"**/*js",
|
||||
"**/node_modules/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "come-down",
|
||||
"name": "Come Down",
|
||||
"version": "1.0.4",
|
||||
"version": "1.1.1",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Maintains a cache of your notes’ embedded external images.",
|
||||
"author": "mntno",
|
||||
|
|
|
|||
33
package.json
33
package.json
|
|
@ -4,28 +4,31 @@
|
|||
"description": "An Obsidian plugin for caching embedded external images.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"lint": "pnpm eslint .",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"build": "(pnpm eslint . || true) && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "mntno",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@codemirror/view": "^6.36.4",
|
||||
"@eslint/js": "9.21.0",
|
||||
"@lezer/common": "^1.2.3",
|
||||
"@types/node": "22.13.9",
|
||||
"@typescript-eslint/eslint-plugin": "8.26.0",
|
||||
"@typescript-eslint/parser": "8.26.0",
|
||||
"builtin-modules": "5.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"globals": "16.0.0",
|
||||
"image-size": "^2.0.1",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/state": "6.5.0",
|
||||
"@codemirror/view": "6.38.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lezer/common": "^1.5.2",
|
||||
"@types/node": "25.6.0",
|
||||
"builtin-modules": "5.1.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.1.9",
|
||||
"globals": "17.5.0",
|
||||
"image-size": "^2.0.2",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.26.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"xxhash-wasm": "1.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
|||
2801
pnpm-lock.yaml
2801
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
120
src/Env.ts
Normal file
120
src/Env.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { App, Platform } from "obsidian";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isDev = !isProduction;
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => { },
|
||||
log: () => { },
|
||||
info: () => { },
|
||||
warn: () => { },
|
||||
};
|
||||
|
||||
const devLogger = isDev ? console : noopLogger;
|
||||
|
||||
export const DevContext = {
|
||||
assert: isDev ? console.assert : () => { },
|
||||
IS_DEV: isDev,
|
||||
runDev: isProduction ? () => { } : (action: () => void) => action(),
|
||||
|
||||
/** @returns The result of evaluating {@link thunk} if {@link IS_DEV} is `true`. */
|
||||
thunkedStr: (thunk: () => string) => isDev ? thunk() : "",
|
||||
|
||||
logCategory: {
|
||||
CACHE_MANAGER: true,
|
||||
DEBUGGING: true,
|
||||
EDIT_UPDATE_PASS: true,
|
||||
POST_PROCESS_PASS: true,
|
||||
WORKAROUNDS: false,
|
||||
} as const,
|
||||
|
||||
icon: {
|
||||
DEBUG: "🐞",
|
||||
CACHE_MANAGER: "📦",
|
||||
EDIT_UPDATE_PASS: "✏️",
|
||||
POST_PROCESS_PASS: "📖",
|
||||
WORKAROUND: "🔧",
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
export const Env = {
|
||||
/** Debug/Dev context */
|
||||
dev: DevContext,
|
||||
isDev: DevContext.IS_DEV,
|
||||
|
||||
/** Only for `null` or `undefined`, etc., checks. Used as a self-documenting check on that behavior. {@link DevContext#assert} */
|
||||
assert: console.assert,
|
||||
|
||||
log: {
|
||||
noop: () => { },
|
||||
|
||||
d: devLogger.debug,
|
||||
l: devLogger.log,
|
||||
i: devLogger.info,
|
||||
w: console.warn,
|
||||
e: console.error,
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
|
||||
debug: DevContext.logCategory.DEBUGGING ? devLogger.info : noopLogger.info,
|
||||
edit: DevContext.logCategory.EDIT_UPDATE_PASS ? devLogger.info : noopLogger.info,
|
||||
read: DevContext.logCategory.POST_PROCESS_PASS ? devLogger.info : noopLogger.info,
|
||||
cm: DevContext.logCategory.CACHE_MANAGER ? devLogger.info : noopLogger.info,
|
||||
workaround: DevContext.logCategory.WORKAROUNDS ? devLogger.info : noopLogger.info,
|
||||
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
||||
},
|
||||
|
||||
perf: {
|
||||
now: (): DOMHighResTimeStamp => performance.now(),
|
||||
log: (text: string, timestamp: DOMHighResTimeStamp) => {
|
||||
const end = performance.now() - timestamp;
|
||||
devLogger.warn(`${text}: ${end} ms`);
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* @param appOrCallback - Either a callback function to execute after the cache is cleared, or an App instance to reload Obsidian.
|
||||
*/
|
||||
clearBrowserCache: (appOrCallback: (() => void) | App) => {
|
||||
if (isDev && Platform.isDesktopApp) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => {
|
||||
if (appOrCallback instanceof App) {
|
||||
// @ts-expect-error
|
||||
appOrCallback.commands.executeCommandById("app:reload");
|
||||
}
|
||||
else
|
||||
appOrCallback();
|
||||
})
|
||||
.catch((error: unknown) => console.error('Error clearing cache:', error));
|
||||
}
|
||||
},
|
||||
|
||||
/** @returns `true` if running in the capacitor-js mobile app or if compiled for development with UI in mobile mode. */
|
||||
get isMobile() {
|
||||
if (Platform.isMobileApp)
|
||||
return true;
|
||||
if (isDev && Platform.isMobile)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
obj: {
|
||||
is: (value: unknown): value is object => typeof value === "object" && value !== null, // `null` is an object
|
||||
} as const,
|
||||
|
||||
str: {
|
||||
EMPTY: "",
|
||||
SPACE: " ",
|
||||
is: (value: unknown): value is string => typeof value === "string",
|
||||
nonEmpty: (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined,
|
||||
isNonEmpty: (value: unknown): value is string => typeof value === "string" && value !== "",
|
||||
} as const,
|
||||
|
||||
bool: {
|
||||
isTrue: (value: unknown): value is boolean => typeof value === "boolean" && value === true,
|
||||
} as const,
|
||||
|
||||
} as const;
|
||||
|
||||
export type LoggerFn = (...args: unknown[]) => void;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { App, Notice as ObsidianNotice, Platform } from "obsidian";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const ENV = {
|
||||
processOnAllViewUpdateChanges: true,
|
||||
dev: !isProduction,
|
||||
debugLog: !isProduction,
|
||||
} as const;
|
||||
|
||||
export const Log = ENV.debugLog
|
||||
? (msg: string, error?: Error): void => {
|
||||
if (error) {
|
||||
console.log(msg, error);
|
||||
} else {
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
: (): void => { };
|
||||
|
||||
export class Notice {
|
||||
private innerNotice: ObsidianNotice;
|
||||
|
||||
static NAME: string = "";
|
||||
static setName(name: string) {
|
||||
this.NAME = name;
|
||||
}
|
||||
|
||||
constructor(msg: string, duration?: number, omit: boolean = false) {
|
||||
this.innerNotice = new ObsidianNotice(`${omit ? "" : `${Notice.NAME}: `}${msg}`, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBrowserCache(appOrCallback: (() => void) | App) {
|
||||
if (ENV.dev && Platform.isDesktopApp) {
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => {
|
||||
if (appOrCallback instanceof App) {
|
||||
// @ts-expect-error
|
||||
app.commands.executeCommandById("app:reload");
|
||||
}
|
||||
else
|
||||
appOrCallback();
|
||||
})
|
||||
.catch((error: any) => console.error('Error clearing cache:', error));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
import { ENV, Log } from "Environment";
|
||||
import { getIcon } from "obsidian";
|
||||
import { Url } from "Url";
|
||||
|
||||
export const enum HTMLElementCacheState {
|
||||
/** Untouched by the plugin. */
|
||||
ORIGINAL = 0,
|
||||
|
||||
/**
|
||||
* The original src has been removed. Element is ready to request.
|
||||
*
|
||||
* A `data` attribute has been set on the element with the original src, get it with {@link HtmlAssistant.originalSrc}.
|
||||
*/
|
||||
ORIGINAL_SRC_REMOVED,
|
||||
|
||||
/**
|
||||
* Requesting cache. If cache found, will be changed to {@link CACHE_SUCCEEDED}; if not, to {@link REQUESTING_DOWNLOADING}.
|
||||
*/
|
||||
REQUESTING,
|
||||
|
||||
/**
|
||||
* Cache item was not found. Downloading.
|
||||
*/
|
||||
REQUESTING_DOWNLOADING,
|
||||
|
||||
/**
|
||||
* The element's src is now pointing to the locally cached item, whether it was fetched from cache or downloaded.
|
||||
*/
|
||||
CACHE_SUCCEEDED,
|
||||
|
||||
/**
|
||||
* Cache was requested but failed. As opposed to {@link INVALID}, these can be retried.
|
||||
* Basically equal to {@link ORIGINAL_SRC_REMOVED} except that they have requested at least once.
|
||||
*
|
||||
* For example, connection failed.
|
||||
*/
|
||||
CACHE_FAILED,
|
||||
|
||||
/**
|
||||
* For example,
|
||||
* - the url doesn't exist (404)
|
||||
* - the url exists but is irrelevant, e.g., https://example.com/
|
||||
*
|
||||
* Elements in this state are ignored. The may or may not have an icon set ({@link HtmlAssistant.setIcon}), e.g., if the URL was requested but resulted in 404.
|
||||
*/
|
||||
INVALID,
|
||||
}
|
||||
|
||||
export const enum HTMLElementAttribute {
|
||||
SRC = "src",
|
||||
ALT = "alt",
|
||||
};
|
||||
|
||||
export class HtmlAssistant {
|
||||
|
||||
public static isElementCacheStateEqual(element: HTMLElement, states: HTMLElementCacheState[]): boolean {
|
||||
return this.isCacheStateEqual(this.cacheState(element), states);
|
||||
}
|
||||
|
||||
public static isCacheStateEqual(state: HTMLElementCacheState, states: HTMLElementCacheState[]): boolean {
|
||||
for (const stateToCheck of states)
|
||||
if (stateToCheck == state)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param element
|
||||
* @returns If {@link element} is lacking a state, {@link HTMLElementCacheState.ORIGINAL} is returned.
|
||||
*/
|
||||
public static cacheState(element: HTMLElement): HTMLElementCacheState {
|
||||
const state = element.dataset.comeDownState;
|
||||
|
||||
if (state === undefined)
|
||||
return HTMLElementCacheState.ORIGINAL;
|
||||
|
||||
const numericState = Number(state);
|
||||
|
||||
if (
|
||||
numericState !== HTMLElementCacheState.ORIGINAL &&
|
||||
numericState !== HTMLElementCacheState.ORIGINAL_SRC_REMOVED &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING_DOWNLOADING &&
|
||||
numericState !== HTMLElementCacheState.CACHE_SUCCEEDED &&
|
||||
numericState !== HTMLElementCacheState.CACHE_FAILED &&
|
||||
numericState !== HTMLElementCacheState.INVALID
|
||||
) {
|
||||
throw new Error(`Invalid cache state: ${state}`);
|
||||
}
|
||||
|
||||
return numericState as HTMLElementCacheState;
|
||||
}
|
||||
|
||||
public static setCacheState(element: HTMLElement, state: HTMLElementCacheState) {
|
||||
element.dataset.comeDownState = state.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods returns before the images have loaded but guarantees that they will load
|
||||
* unless the returned result's `error` is set.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param src
|
||||
* @returns Assume file not found when an {@link Error} is returned, see {@link createBlobObjectUrl}.
|
||||
*/
|
||||
public static async loadImages(imageElements: HTMLImageElement[], src: string) {
|
||||
const result = await this.createBlobObjectUrl(src);
|
||||
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
error: result,
|
||||
fileNotFound: result instanceof HtmlAssistantFileNotFoundError,
|
||||
};
|
||||
}
|
||||
else {
|
||||
let remainingLoads = imageElements.length;
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.removeEventListener("load", onLoad);
|
||||
|
||||
remainingLoads--;
|
||||
if (remainingLoads === 0) {
|
||||
URL.revokeObjectURL(result);
|
||||
}
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, result);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the data sets from the element.
|
||||
* @param element
|
||||
*/
|
||||
public static resetElement(element: HTMLElement) {
|
||||
delete element.dataset.comeDownOriginalSource;
|
||||
delete element.dataset.comeDownState;
|
||||
}
|
||||
|
||||
public static setFailed(element: HTMLElement) {
|
||||
this.setCacheState(element, HTMLElementCacheState.CACHE_FAILED);
|
||||
if (element instanceof HTMLImageElement)
|
||||
this.setIcon(element, this.failedIcon);
|
||||
}
|
||||
|
||||
public static setInvalid(element: HTMLElement) {
|
||||
this.setCacheState(element, HTMLElementCacheState.INVALID);
|
||||
if (element instanceof HTMLImageElement)
|
||||
this.setIcon(element, this.failedIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Removes the `src` attribute to prevent ordinary loading.
|
||||
* - Sets the state to {@link HTMLElementCacheState.ORIGINAL_SRC_REMOVED}.
|
||||
* - Will not do anything if: `src` attrib is missing or empty string; if the original src already has been set to the data set.
|
||||
*
|
||||
* @param imageElements
|
||||
*/
|
||||
public static cancelImageLoading(imageElements: HTMLImageElement[]) {
|
||||
|
||||
const cancelImageLoad = (imageElement: HTMLImageElement): boolean => {
|
||||
|
||||
// Only continue if there's something to cancel.
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
if (!src) // Empty string is falsy.
|
||||
return false;
|
||||
|
||||
if (ENV.debugLog && src && Url.isBlob(src)) {
|
||||
console.warn(`cancelImageLoading: Setting dataset to blob.`);
|
||||
}
|
||||
|
||||
imageElement.dataset.comeDownOriginalSource = src;
|
||||
imageElement.removeAttribute(HTMLElementAttribute.SRC);
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
imageElements.forEach((imageElement) => {
|
||||
if (cancelImageLoad(imageElement))
|
||||
counter++;
|
||||
});
|
||||
|
||||
Log(`cancelImageLoading: Cancelled ${counter} of ${imageElements.length}.`);
|
||||
}
|
||||
|
||||
public static originalSrc(element: HTMLImageElement) {
|
||||
return element.dataset.comeDownOriginalSource ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on what's available in the HTML, returns image element's actual original source.
|
||||
* @returns The src or `null` if not available.
|
||||
*/
|
||||
public static imageElementOriginalSrc(element: HTMLImageElement): string | null {
|
||||
|
||||
const os = element.dataset.comeDownOriginalSource;
|
||||
if (os)
|
||||
return os;
|
||||
|
||||
const hasAttribute = element.hasAttribute(HTMLElementAttribute.SRC);
|
||||
return hasAttribute && element.src.trim().length > 0 ? element.src : null;
|
||||
|
||||
/*
|
||||
switch (this.cacheState(element)) {
|
||||
case HTMLElementCacheState.ORIGINAL:
|
||||
return hasAttribute && element.src.trim().length > 0 ? element.src : null;
|
||||
case HTMLElementCacheState.INIT:
|
||||
case HTMLElementCacheState.CACHE_REQUEST:
|
||||
case HTMLElementCacheState.CACHE_SUCCESS:
|
||||
case HTMLElementCacheState.CACHE_FAILED: {
|
||||
|
||||
const os = element.dataset.comeDownOriginalSource;
|
||||
if (os)
|
||||
return os;
|
||||
else if (hasAttribute) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;*/
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This method is intended for finding unprocessed elements.
|
||||
*
|
||||
* - 'img' selects all <img> elements.
|
||||
* - [src] selects only <img> elements that have a src attribute.
|
||||
* - :not([aria-hidden="true"]) filters out <img> elements that have the aria-hidden="true" attribute.
|
||||
*
|
||||
* @param element
|
||||
* @param requireSrcAttribute If `true` will not return image elements that don't have the `src` attribute.
|
||||
* @param filter
|
||||
* @returns
|
||||
*/
|
||||
public static findRelevantImagesToProcess(element: HTMLElement, requireSrcAttribute: boolean = true, filter?: (imageElement: HTMLImageElement) => boolean): HTMLImageElement[] {
|
||||
let imageElements;
|
||||
if (requireSrcAttribute)
|
||||
imageElements = element.findAll('img[src]:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
else
|
||||
imageElements = element.findAll('img:not([aria-hidden="true"])') as HTMLImageElement[];
|
||||
|
||||
return filter ? imageElements.filter((imageElement) => filter(imageElement)) : imageElements;
|
||||
|
||||
/*
|
||||
syntaxTree(view.state).iterate({
|
||||
enter: ({ type, from, to }: SyntaxNodeRef) => {
|
||||
console.log(`${view.state.doc.sliceString(from, to)}`);
|
||||
}
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications#using_object_urls|Using object URLs}
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If {@link src} points to a local file that doesn't exist, the Dev Tools will show "net::ERR_FILE_NOT_FOUND".
|
||||
* The net::ERR_FILE_NOT_FOUND message you see in the console comes from the browser’s internal networking layer, only shown in DevTools, not available to JavaScript code.
|
||||
* However, in Electron and Chrome-based browsers, fetch() throwing a TypeError generally means the file doesn’t exist.
|
||||
*
|
||||
* Capacitor also, which runs on the native mobile webview, also throws a `TypeError` on iOS (not tested on Android).
|
||||
*
|
||||
* To be safe, assume all `Error`s is file not found.
|
||||
*
|
||||
* @param src Url to a local resource. Would start with `app://`, `capacitor://`, or perhaps `file://`
|
||||
* @returns On failure, a {@link HtmlAssistantFileNotFoundError} if it thinks the a give local file doesn't exist; otherwise a normal Error.
|
||||
*/
|
||||
public static async createBlobObjectUrl(src: string): Promise<string | Error> {
|
||||
let response;
|
||||
|
||||
try {
|
||||
// Response from `requestUrl` does not allow for creating blobs. These will always be local urls, so no need to handle CORS.
|
||||
response = await fetch(src);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
return new HtmlAssistantFileNotFoundError(src);
|
||||
else
|
||||
return error;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
try {
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (url && URL.canParse(url) && Url.isBlob("blob:"))
|
||||
return url;
|
||||
else
|
||||
return new Error(`Invalid blob URL: ${url}`);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return new Error(`Unsuccessful response: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static setLoadingIcon(imageElement: HTMLImageElement) {
|
||||
this.setIcon(imageElement, this.loadingIcon);
|
||||
}
|
||||
|
||||
private static setIcon(imageElement: HTMLImageElement, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const onLoad = (_event: Event) => {
|
||||
imageElement.removeEventListener("load", onLoad);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link https://lucide.dev/icons/loader}
|
||||
*/
|
||||
private static get loadingIcon(): Blob {
|
||||
if (this.loadingIconBacking === undefined) {
|
||||
const icon = getIcon("loader")
|
||||
console.assert(icon, "loader icon id not found");
|
||||
icon!.setAttribute("stroke", "#919191");
|
||||
icon!.setAttribute("stroke-width", "1");
|
||||
this.loadingIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.loadingIconBacking;
|
||||
}
|
||||
private static loadingIconBacking?: Blob;
|
||||
|
||||
private static get failedIcon(): Blob {
|
||||
if (this.failedIconBacking === undefined) {
|
||||
const icon = getIcon("image")
|
||||
console.assert(icon, "image icon id not found");
|
||||
icon!.setAttribute("stroke", "#ff0000");
|
||||
icon!.setAttribute("stroke-width", "1");
|
||||
this.failedIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.failedIconBacking;
|
||||
}
|
||||
private static failedIconBacking?: Blob;
|
||||
}
|
||||
|
||||
class HtmlAssistantFileNotFoundError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`File not found: ${url}`);
|
||||
this.name = "HtmlAssistantFileNotFoundError";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { MarkdownView, MarkdownPostProcessorContext, FileView, TFile, App } from "obsidian";
|
||||
import { Log, ENV } from "Environment";
|
||||
import { CacheRequest, CacheManager } from "CacheManager";
|
||||
|
||||
/**
|
||||
* - Gathers some state useful during the current pass.
|
||||
* - Call first in system callbacks to abort as early as possible.
|
||||
*/
|
||||
export class ProcessingPass {
|
||||
|
||||
private markdownView: MarkdownView | null;
|
||||
public associatedFile: TFile;
|
||||
|
||||
/** `true` when invoked by the Markdown post processor. */
|
||||
public isInPostProcessingPass: boolean;
|
||||
private viewUpdate?: ViewUpdate;
|
||||
|
||||
private static updatePassID: number = 0;
|
||||
private static postProcessorPassID: number = 0;
|
||||
public passID: number;
|
||||
|
||||
public static beginFromViewUpdate(app: App, viewUpdate: ViewUpdate, noFile: () => void, sourceMode?: () => void): ProcessingPass | null {
|
||||
|
||||
const processPassID = this.updatePassID++;
|
||||
|
||||
if (ENV.dev) {
|
||||
|
||||
const updated = this.viewUpdateChanges(viewUpdate);
|
||||
const changed = Object.keys(updated).filter(key => updated[key]);
|
||||
const notChanged = Object.keys(updated).filter(key => !updated[key]);
|
||||
|
||||
Log(`🥎 editorViewUpdateListener ${changed.length} view updates. 🛎️ ID ${processPassID}`);
|
||||
if (changed.length > 0)
|
||||
Log(`\t🟢 ${changed.join(", ")}`);
|
||||
if (changed.length > 0 && notChanged.length > 0)
|
||||
Log(`\t🔴 ${notChanged.join(", ")}`);
|
||||
}
|
||||
|
||||
const markdownView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
const associatedFile = markdownView?.file ?? null;
|
||||
|
||||
// `app.workspace.getActiveFile()` is of no help.
|
||||
if (!associatedFile) {
|
||||
Log(`\tNo associated file. ID ${processPassID}`);
|
||||
noFile();
|
||||
return null;
|
||||
}
|
||||
else if (markdownView && this.isInSourceMode(markdownView)) {
|
||||
sourceMode?.();
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
const instance = new this();
|
||||
instance.passID = processPassID;
|
||||
instance.markdownView = markdownView;
|
||||
instance.associatedFile = associatedFile;
|
||||
instance.isInPostProcessingPass = false;
|
||||
instance.viewUpdate = viewUpdate;
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
public static beginFromPostProcessorContext(app: App, context: MarkdownPostProcessorContext) : ProcessingPass {
|
||||
|
||||
const postProcessorPassID = this.postProcessorPassID++;
|
||||
|
||||
Log(`🏀 postProcessReadingModeHtml 🛎️ ID ${postProcessorPassID}`);
|
||||
|
||||
const instance = new this();
|
||||
instance.passID = postProcessorPassID;
|
||||
instance.isInPostProcessingPass = true;
|
||||
|
||||
instance.markdownView = app.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
let associatedFile = instance.markdownView?.file ?? app.vault.getFileByPath(context.sourcePath);
|
||||
console.assert(associatedFile);
|
||||
instance.associatedFile = associatedFile!;
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static viewUpdateChanges(viewUpdate: ViewUpdate) {
|
||||
const u: Record<string, boolean> = {};
|
||||
|
||||
u["docChanged"] = viewUpdate.docChanged;
|
||||
u["viewportChanged"] = viewUpdate.viewportChanged;
|
||||
u["selectionSet"] = viewUpdate.selectionSet;
|
||||
u["focusChanged"] = viewUpdate.focusChanged;
|
||||
u["geometryChanged"] = viewUpdate.geometryChanged;
|
||||
u["heightChanged"] = viewUpdate.heightChanged;
|
||||
u["viewportMoved"] = viewUpdate.viewportMoved;
|
||||
|
||||
return u;
|
||||
}
|
||||
|
||||
private static isInLivePreviewFromView(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == false : false;
|
||||
}
|
||||
|
||||
private static isInSourceMode(view: FileView) {
|
||||
const state = view?.getState();
|
||||
return state ? state.mode == "source" && state.source == true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link EditorView.updateListener.of} {@link Extension} is called in both reader and preview/source mode and let's you process all elements at once.
|
||||
* The {@link registerMarkdownPostProcessor} is only called first when the file is opened in reader mode. It is called several times as the DOM is beeing built, which makes it complicated to consolidate them into one call.
|
||||
*
|
||||
* Because the update listener extension callback supplies the DOM of the whole file's writing area, and further, because it is always run before the Markdown post processor,
|
||||
* it is enough and simplest to show the download notice only when invoked from the extension callback, i.e., when {@link processingContext.isInPostProcessingPass} is `true`.
|
||||
*
|
||||
*
|
||||
* @param processingContext
|
||||
* @param run
|
||||
* @returns
|
||||
*/
|
||||
public runInUpdatePass(run?: () => void) {
|
||||
if (this.isInUpdatePass) {
|
||||
run?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get isInUpdatePass() {
|
||||
return this.viewUpdate ? true : false;
|
||||
}
|
||||
|
||||
public retainCacheFromRequest(request: CacheRequest) {
|
||||
console.assert(request.requesterPath == this.associatedFile.path);
|
||||
if (this.isInUpdatePass)
|
||||
this.requestsToRetain.push(request);
|
||||
}
|
||||
|
||||
public get currentNumberOfRequestsToRetain() {
|
||||
return this.requestsToRetain.length;
|
||||
}
|
||||
|
||||
private requestsToRetain: CacheRequest[] = [];
|
||||
|
||||
public end(cacheManager: CacheManager) {
|
||||
Log(`${this.isInUpdatePass ? `🥎 ProcessingPass:end: Update pass ✅ ID ${this.passID}` : `🏀 ProcessingPass:end: Post processing pass ✅ ID ${this.passID}`}`);
|
||||
|
||||
if (this.isInUpdatePass) {
|
||||
cacheManager.updateRetainedCaches(this.requestsToRetain, this.associatedFile.path).then(() => {
|
||||
Log(`\tSaving metadata: ID ${this.passID}`);
|
||||
cacheManager.saveMetadataIfDirty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public abort() {
|
||||
Log(`${this.isInUpdatePass ? `🥎 ProcessingPass:abort: Update pass ⛔ ID ${this.passID}` : `🏀 ProcessingPass:end: Post processing pass ⛔ ID ${this.passID}`}`);
|
||||
}
|
||||
|
||||
}
|
||||
113
src/Settings.ts
113
src/Settings.ts
|
|
@ -1,21 +1,24 @@
|
|||
import { PluginSettingTab, Setting, Plugin, Platform } from "obsidian";
|
||||
import { ENV, Notice } from "Environment";
|
||||
import { CacheManager } from "CacheManager";
|
||||
import { CacheManager } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { Plugin, PluginSettingTab, Setting } from "obsidian";
|
||||
import { Notice } from "ui/Notice";
|
||||
|
||||
export interface PluginSettings {
|
||||
|
||||
/** Show a {@link Notice} when file download starts. */
|
||||
noticeOnDownload: boolean
|
||||
noticeOnDownload: boolean;
|
||||
/** Show a {@link Notice} when sync conflict files were detected and deleted. */
|
||||
noticeOnDeleteSyncConflictFile: boolean;
|
||||
|
||||
/** Remove the name of the plugin when showing the download message. */
|
||||
omitNameInNotice: boolean;
|
||||
|
||||
/**
|
||||
* Whether to "gitignore" the cache dir.
|
||||
* When set to: `true`, make sure there's a `.gitignore` file in the cache directory; `false` to make sure otherwise.
|
||||
*
|
||||
* This can't be disabled in the UI. But those who take matters into their own hands with data.json...
|
||||
*/
|
||||
* Whether to "gitignore" the cache dir.
|
||||
* When set to: `true`, make sure there's a `.gitignore` file in the cache directory; `false` to make sure otherwise.
|
||||
*
|
||||
* This can't be disabled in the UI. But those who take matters into their own hands with data.json...
|
||||
*/
|
||||
gitIgnoreCacheDir: boolean;
|
||||
|
||||
showDebugInfo: boolean;
|
||||
|
|
@ -26,10 +29,11 @@ type SettingsChanged = (settings: PluginSettings) => void;
|
|||
export class SettingsManager {
|
||||
public settings: PluginSettings;
|
||||
public save: () => Promise<void>;
|
||||
public onChangedCallback: (name: string, value: any) => void | undefined;
|
||||
public onChangedCallback: (name: string, value: unknown) => void | undefined;
|
||||
|
||||
static readonly DEFAULT_SETTINGS: PluginSettings = {
|
||||
noticeOnDownload: true,
|
||||
noticeOnDeleteSyncConflictFile: false,
|
||||
omitNameInNotice: false,
|
||||
gitIgnoreCacheDir: true,
|
||||
showDebugInfo: false,
|
||||
|
|
@ -39,7 +43,7 @@ export class SettingsManager {
|
|||
gitIgnoreCacheDir: "gitIgnoreCacheDir",
|
||||
} as const;
|
||||
|
||||
constructor(settings: any, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string, value: any) => void | undefined) {
|
||||
constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string, value: unknown) => void | undefined) {
|
||||
this.settings = settings;
|
||||
this.save = () => save(this.settings);
|
||||
this.onChangedCallback = onChangedCallback;
|
||||
|
|
@ -65,6 +69,7 @@ export class SettingsManager {
|
|||
export class SettingTab extends PluginSettingTab {
|
||||
private settingsManager: SettingsManager;
|
||||
private cacheManager: CacheManager;
|
||||
private isShown = false;
|
||||
|
||||
constructor(plugin: Plugin, settingsManager: SettingsManager, cacheManager: CacheManager) {
|
||||
super(plugin.app, plugin);
|
||||
|
|
@ -72,16 +77,32 @@ export class SettingTab extends PluginSettingTab {
|
|||
this.cacheManager = cacheManager;
|
||||
}
|
||||
|
||||
public display(): void {
|
||||
Env.log.d("SettingTab:display: isShown", this.isShown);
|
||||
|
||||
if (!this.isShown)
|
||||
this.settingsManager.registerOnChangedCallback(this.onChangedCallback);
|
||||
this.isShown = true;
|
||||
|
||||
this.cacheManager.checkIfMetadataFileChangedExternally().then(() => this.render());
|
||||
}
|
||||
|
||||
override hide(): void {
|
||||
Env.log.d("SettingTab:hide");
|
||||
if (this.isShown)
|
||||
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
|
||||
this.isShown = false;
|
||||
}
|
||||
|
||||
private onChangedCallback = () => this.display();
|
||||
|
||||
display(): void {
|
||||
this.settingsManager.registerOnChangedCallback(this.onChangedCallback);
|
||||
private async render() {
|
||||
const { containerEl } = this;
|
||||
const settings = this.settingsManager.settings;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
let compactDownloadMessage: Setting | undefined;
|
||||
let compactDownloadMessage: Setting | undefined = undefined;
|
||||
const setCompactDownloadMessageVisibility = () => {
|
||||
if (settings.noticeOnDownload)
|
||||
compactDownloadMessage?.settingEl.show();
|
||||
|
|
@ -114,45 +135,41 @@ export class SettingTab extends PluginSettingTab {
|
|||
setCompactDownloadMessageVisibility();
|
||||
|
||||
const cachedFilesSetting = new Setting(containerEl).setName('Number of files cached');
|
||||
setTimeout(() => {
|
||||
this.cacheManager.actualCachedFilePaths().then((filePaths) => {
|
||||
const num = filePaths.length;
|
||||
cachedFilesSetting.setDesc(`${num} file${num == 1 ? `` : `s`}.`);
|
||||
this.cacheManager.actualCachedFilePaths().then((filePaths) => {
|
||||
const num = filePaths.length;
|
||||
cachedFilesSetting.setDesc(`${num} file${num == 1 ? `` : `s`}.`);
|
||||
|
||||
if (num == 0)
|
||||
return;
|
||||
if (num == 0)
|
||||
return;
|
||||
|
||||
cachedFilesSetting.addButton((button) => {
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete all cached files");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
cachedFilesSetting.addButton((button) => {
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete all cached files");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
|
||||
await this.cacheManager.clearCached((error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
console.error("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
cachedFilesSetting.setDesc("Cache is empty.")
|
||||
await this.cacheManager.clearCached((error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
Env.log.e("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
cachedFilesSetting.setDesc("Cache is empty.")
|
||||
|
||||
button.buttonEl.remove();
|
||||
button.buttonEl.remove();
|
||||
|
||||
if (ENV.dev && Platform.isDesktopApp) {
|
||||
require('electron').remote.session.defaultSession.clearCache()
|
||||
.then(() => new Notice('Electron Cache cleared successfully. Restart vault.'))
|
||||
.catch((error: any) => console.error('Error clearing cache:', error));
|
||||
}
|
||||
}
|
||||
});
|
||||
Env.clearBrowserCache(() => new Notice('Electron Cache cleared successfully. Restart vault.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
});
|
||||
}).catch(error => {
|
||||
Env.log.e("Failed to count cached files:", error);
|
||||
cachedFilesSetting.setDesc("Failed to count cached files.");
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -177,12 +194,8 @@ export class SettingTab extends PluginSettingTab {
|
|||
settings.gitIgnoreCacheDir = value;
|
||||
await this.settingsManager.save();
|
||||
refreshDisabled();
|
||||
this.settingsManager?.onChangedCallback(SettingsManager.SETTING_NAME.gitIgnoreCacheDir, value);
|
||||
this.settingsManager.onChangedCallback(SettingsManager.SETTING_NAME.gitIgnoreCacheDir, value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/Url.ts
102
src/Url.ts
|
|
@ -1,102 +0,0 @@
|
|||
|
||||
|
||||
export class Url {
|
||||
|
||||
/**
|
||||
* These are case-sensitive. Use with {@link normalizedHeaders}.
|
||||
*/
|
||||
public static readonly RESPONSE_HEADER_LOWERCASE = {
|
||||
contentType: "Content-Type".toLowerCase(),
|
||||
contentLength: "Content-Length".toLowerCase(),
|
||||
cacheControl: "Cache-Control".toLowerCase(),
|
||||
expires: "Expires".toLowerCase(),
|
||||
} as const;
|
||||
|
||||
public static readonly CACHE_CONTROL_LOWERCASE = {
|
||||
noStore: "no-store",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Normalize headers to make sure to, e.g., find both `Content-Type` and `content-type`.
|
||||
* Also ignores empty strings and trims.
|
||||
*/
|
||||
public static normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const normalizedHeaders: Record<string, string> = {};
|
||||
|
||||
for (const key in headers)
|
||||
if (key.length > 0)
|
||||
normalizedHeaders[key.toLowerCase().trim()] = headers[key];
|
||||
|
||||
return normalizedHeaders;
|
||||
}
|
||||
|
||||
public static isValid(url: string): boolean {
|
||||
return url && URL.canParse(url) ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* These are not relevant: ftp, mailto, tel, ws: and wss:
|
||||
*/
|
||||
public static isExternal(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static isEmbedded(url: string): boolean {
|
||||
return this.isBlob(url) || url.startsWith("data:");
|
||||
}
|
||||
|
||||
public static isBlob(url: string): boolean {
|
||||
return url.startsWith("blob:"); // Note: no slashes
|
||||
}
|
||||
|
||||
public static isLocal(url: string): boolean {
|
||||
// Slashes are better: e.g., "app:data" or "file:info" are not URLs.
|
||||
return url.startsWith("app://") || url.startsWith("capacitor://") || url.startsWith("file://");
|
||||
}
|
||||
|
||||
public static trimBackslash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author Gemini
|
||||
* @param url
|
||||
* @returns
|
||||
*/
|
||||
public static extractFilenameAndExtension(url: string): { filename: string, extension: string } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
if (!pathname) {
|
||||
return null; // No pathname, cannot extract filename
|
||||
}
|
||||
|
||||
const filenameWithExtension = pathname.substring(pathname.lastIndexOf('/') + 1).split('?')[0]; // Remove query params
|
||||
|
||||
if (!filenameWithExtension) {
|
||||
return null; // No filename found
|
||||
}
|
||||
|
||||
const lastDotIndex = filenameWithExtension.lastIndexOf('.');
|
||||
|
||||
if (lastDotIndex === -1) {
|
||||
return { filename: filenameWithExtension, extension: '' }; // No extension
|
||||
}
|
||||
|
||||
const filename = filenameWithExtension.substring(0, lastDotIndex);
|
||||
const extension = filenameWithExtension.substring(lastDotIndex + 1);
|
||||
|
||||
return { filename, extension };
|
||||
} catch (error) {
|
||||
console.error(`Error parsing URL: ${error}`);
|
||||
return null; // Invalid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { Log } from "Environment";
|
||||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { Url } from "Url";
|
||||
import { HtmlAssistant, HTMLElementAttribute, HTMLElementCacheState } from "HtmlAssistant";
|
||||
|
||||
|
||||
export class Workarounds {
|
||||
|
||||
/**
|
||||
* If there is a Markdown link at any point after an empty embedded syntax
|
||||
* (i.e., "\!\[\]\(\)" or "\!\[\]\(") an `img` element will be insterted with the `src` attribute
|
||||
* set to the `href` of the link!
|
||||
*
|
||||
* Therefore, when the user types "\!\[\]\(\)" the plugin will start downloading the `href` url.
|
||||
*
|
||||
* Outstanding related issues:
|
||||
*
|
||||
* - As this method only looks at changes, if a note is loaded containing "\!\[\]\(\)" or "\!\[\]\(") with a Markdown link after it, it slips through.
|
||||
* - When note contain "\!\[\]\(", moving the cursor around seems to reset the inserted `img`, which will trigger download.
|
||||
* - If the Markdown link is modified while there is a "\!\[\]\(\)" or "\!\[\]\(" above it.
|
||||
* - If there is a code block after "\!\[\]\(\)" or "\!\[\]\(" containing a Markdown link this link will not cause the issue to arise, but the regex matches with that link rather than any link after the code block which is the real cause.
|
||||
* Pursuing this particular edge case is not worth it because it will likely include to regex searches to discard Markdown links in code blocks.
|
||||
*
|
||||
* The additional processing in these rare edge cases might not be worth addressing.
|
||||
* The important part is to prevent downloads when the user is engaging with the link.
|
||||
*
|
||||
* @param update
|
||||
* @returns The URLs of `img` elements in the DOM that should be ignored or `null` if there are none.
|
||||
*/
|
||||
public static detectSourcesOfInvalidImageElements(update: ViewUpdate) {
|
||||
if (update.changes.empty)
|
||||
return null;
|
||||
|
||||
var sourcesToIgnore: string[] = [];
|
||||
|
||||
update.changes.iterChanges((_fromA, _toA, cursorStartPos, cursorEndPosition, inserted) => {
|
||||
|
||||
const insertedText = inserted.toString();
|
||||
const doc = update.state.doc;
|
||||
const line = doc.lineAt(cursorStartPos);
|
||||
const modifiedLine = doc.sliceString(line.from, line.to) + "\n";
|
||||
|
||||
Log(`Workarounds: Inserted ${cursorStartPos}/${cursorEndPosition}: ${insertedText}`);
|
||||
Log(`Workarounds: Modified line: ${modifiedLine}, match: ${this.embeddedImageRegex.test(modifiedLine)}`);
|
||||
|
||||
if (this.embeddedImageRegex.test(modifiedLine)) {
|
||||
|
||||
// Scan for the first markdown link after the cursor
|
||||
const textAfterCursor = doc.sliceString(cursorEndPosition);
|
||||
const match = this.markdownLinkRegex.exec(textAfterCursor);
|
||||
|
||||
//Log(`Text after: ${textAfterCursor}`);
|
||||
|
||||
if (match) {
|
||||
const linkUrl = match[1];
|
||||
Log(`Workarounds: Found image src to ignore: ${linkUrl}`);
|
||||
|
||||
// The image element is only inserted when the link protocol is recognized, i.e.,
|
||||
// when it begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`.
|
||||
// So no need to check if the url is valid or external here. Insert.
|
||||
sourcesToIgnore.push(linkUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sourcesToIgnore.length > 0 ? sourcesToIgnore : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches all cases where the invalid image element is inserted.
|
||||
*
|
||||
* - \!\[\]\(\)
|
||||
* - \!\[\]\(
|
||||
* - \!\[abc\]\(\)
|
||||
* - \!\[\]\(abc
|
||||
*/
|
||||
private static readonly embeddedImageRegex = /!\[(.*?)\]\((.*?)\)?/;
|
||||
|
||||
/**
|
||||
* Use to find the first Markdown link in a string.
|
||||
*
|
||||
* Will not match if there's no link because an img element is not inserted until the link
|
||||
* begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`.
|
||||
*/
|
||||
private static readonly markdownLinkRegex = /\[.*?\]\((.+?)\)/;
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sourcesToIgnore Result of having called {@link detectSourcesOfInvalidImageElements}
|
||||
* @param imageElement
|
||||
* @param src The `src` of the {@link imageElement} parameter. Check existance before calling.
|
||||
* @returns `false` when the {@link imageElement}'s state was set to {@link HTMLElementCacheState.INVALID} and should be filtered out of furter processing.
|
||||
*/
|
||||
public static HandleInvalidImageElements(sourcesToIgnore: string[] | null, imageElement: HTMLImageElement, src: string): boolean {
|
||||
if (sourcesToIgnore) {
|
||||
for (const sourceToIgnore of sourcesToIgnore) {
|
||||
if (Url.trimBackslash(sourceToIgnore) === Url.trimBackslash(src)) {
|
||||
imageElement.removeAttribute(HTMLElementAttribute.SRC); // Shouldn't be necessary but why keep it.
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.INVALID); // Set to invalid so that the next pass ignores it.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
385
src/CacheManager.ts → src/cache/CacheManager.ts
vendored
385
src/CacheManager.ts → src/cache/CacheManager.ts
vendored
|
|
@ -1,13 +1,13 @@
|
|||
import { CacheMetadata, CacheMetadataImage, CacheRetainer, CacheRoot, CacheType, EMPTY_CACHE_ROOT } from "cache/CacheMetadata";
|
||||
import { Env } from "Env";
|
||||
import { imageSize } from "image-size";
|
||||
import { normalizePath, requestUrl, Vault } from "obsidian";
|
||||
import { imageSize } from 'image-size'
|
||||
import { Logger } from "utils/Logger";
|
||||
import { Url } from "utils/Url";
|
||||
import { Err } from "utils/ts";
|
||||
import xxhash, { XXHashAPI } from "xxhash-wasm";
|
||||
import { CacheRoot, CacheMetadata, CacheMetadataImage, CacheRetainer, CacheType, EMPTY_CACHE_ROOT } from "CacheMetadata";
|
||||
import { ENV, Log } from "Environment";
|
||||
import { Url } from "Url";
|
||||
|
||||
|
||||
//#region
|
||||
|
||||
export interface CacheRequest {
|
||||
/**
|
||||
* Unique key to identify the source of what is requested.
|
||||
|
|
@ -17,6 +17,8 @@ export interface CacheRequest {
|
|||
|
||||
/** @todo Can do without. However, this makes each {@link CacheRequest} unique per requester. */
|
||||
requesterPath: string;
|
||||
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
|
@ -82,23 +84,23 @@ export class CacheFetchError extends CacheError {
|
|||
|
||||
constructor(cacheKey: string, readonly error: Error | null, readonly info: { readonly sourceUrl: string, readonly statusCode?: number }) {
|
||||
|
||||
super(cacheKey, `Failed to fetch cache: ${cacheKey}. Status: ${info?.statusCode ?? "Unknown"}`, { cause: error });
|
||||
super(cacheKey, `Failed to fetch cache: ${cacheKey}. Status: ${info.statusCode ?? "Unknown"}`, { cause: error });
|
||||
|
||||
if (error) {
|
||||
if (error.message && error.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
|
||||
Log(`Domain name resolution error: ${info.sourceUrl} - ${error.message}`);
|
||||
Env.log.e(`Domain name resolution error: ${info.sourceUrl} - ${error.message}`);
|
||||
} else if (error instanceof URIError) {
|
||||
Log("Invalid URL provided.");
|
||||
Env.log.e("Invalid URL provided.");
|
||||
} else if (error.message && error.message.includes("net::ERR_INTERNET_DISCONNECTED")) {
|
||||
Log("Network error: Could not connect to the server.");
|
||||
Env.log.e("Network error: Could not connect to the server.");
|
||||
this.isRetryable = true;
|
||||
this.isInternetDisconnected = true;
|
||||
} else {
|
||||
Log("Request failed:", error);
|
||||
Env.log.e("Request failed:", error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.isRetryable = !this.info?.statusCode || (this.info?.statusCode >= 500 && this.info?.statusCode < 600);
|
||||
this.isRetryable = !this.info.statusCode || (this.info.statusCode >= 500 && this.info.statusCode < 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -115,14 +117,12 @@ interface FileInfo {
|
|||
extension: string;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
/** Use with {@link CacheManager.registerMetadataChanged} */
|
||||
export type MetadataChanged = (data: CacheRoot) => void;
|
||||
|
||||
export class CacheManager {
|
||||
|
||||
private metadataRoot: CacheRoot;
|
||||
private metadataRoot: CacheRoot = EMPTY_CACHE_ROOT;
|
||||
|
||||
private get cache(): Record<string, CacheMetadata> {
|
||||
return this.metadataRoot.items;
|
||||
|
|
@ -136,11 +136,10 @@ export class CacheManager {
|
|||
private readonly metadataFilePath: string;
|
||||
|
||||
private static hasher: XXHashAPI;
|
||||
private static isHasherInitialized = false;
|
||||
|
||||
private readonly filePathsToOmitWhenClearingCache: string[]
|
||||
|
||||
//#region
|
||||
|
||||
private constructor(vault: Vault, cacheDir: string, metadataFilePath: string, filePathsToOmitWhenClearingCache: string[]) {
|
||||
this.vault = vault;
|
||||
this.cacheDir = cacheDir;
|
||||
|
|
@ -150,8 +149,10 @@ export class CacheManager {
|
|||
|
||||
public static async create(vault: Vault, cacheDir: string, metadataFilePath: string, filePathsToOmitWhenClearingCache: string[] = []) {
|
||||
const instance = new this(vault, cacheDir, metadataFilePath, filePathsToOmitWhenClearingCache);
|
||||
if (!this.hasher)
|
||||
this.hasher = await xxhash(); // Seems to be quick.
|
||||
if (!CacheManager.isHasherInitialized) {
|
||||
CacheManager.hasher = await xxhash(); // Seems to be quick.
|
||||
CacheManager.isHasherInitialized = true;
|
||||
}
|
||||
|
||||
await instance.initCache(); // This can be called lazily if it turns out reading the json file takes too long time.
|
||||
|
||||
|
|
@ -168,14 +169,12 @@ export class CacheManager {
|
|||
if (this.initPromise)
|
||||
return this.initPromise; // If there's an ongoing initialization, wait for it to complete.
|
||||
|
||||
Log(`CacheManager:initCache`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "CacheManager:initCache");
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
await this.loadMetadata();
|
||||
this.cacheInitiated = true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
this.initPromise = null; // Reset when done.
|
||||
}
|
||||
|
|
@ -186,8 +185,6 @@ export class CacheManager {
|
|||
private cacheInitiated = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Call before making requests.
|
||||
*
|
||||
|
|
@ -202,120 +199,208 @@ export class CacheManager {
|
|||
return new Error(`The cache key must be an external Url (${request.source})`);
|
||||
}
|
||||
|
||||
//#region Retain/Release
|
||||
|
||||
/**
|
||||
* - Only cached/downloaded resources can be retained
|
||||
* - Only if they are actually refrenced should they be retained.
|
||||
* - Each retainer only retains the cache item once, even if used more than once.
|
||||
*
|
||||
* Each time the retained caches are updated by a retainer a diff is made with the previous
|
||||
* retained caches, which reveals references that were added and deleted.
|
||||
* If a reference was deleted, the total retain count for that reference is checked, and if it's now 0,
|
||||
* it is marked for deletion.
|
||||
*
|
||||
* @param requests
|
||||
* @returns
|
||||
*/
|
||||
public async updateRetainedCaches(requests: CacheRequest[], retainerPath: string) {
|
||||
* A primary purpose of this method is to delete cached items whose retain count goes down to zero.
|
||||
*
|
||||
* Each time the retained caches are updated by a retainer, a diff is made with the previous
|
||||
* retained caches, which reveals references that were added and deleted.
|
||||
*
|
||||
* If a reference was deleted, the total retain count for that reference is checked, and if it's now 0,
|
||||
* it is marked for deletion.
|
||||
*
|
||||
* - Only cached/downloaded resources can be retained, i.e., the cache metadata must exist.
|
||||
* - Each retainer only retains the cache item once, even if used more than once.
|
||||
*
|
||||
* @param requests Multiple identical {@link CacheRequest} will be reduced to one. References to non-existent caches will be ignored.
|
||||
* @param retainerPath The path to the retainer (file). This is needed to know which retainer to release references, e.g., is {@link requests} is empty. Each {@link CacheRequest#requesterPath} of {@link requests} is expected to be equal to this.
|
||||
* @param options
|
||||
*/
|
||||
public async updateRetainedCaches(requests: CacheRequest[], retainerPath: string, options?: {
|
||||
/** These will not be touched, i.e., neither retained nor released. */
|
||||
requestsToIgnore?: Set<CacheRequest>;
|
||||
/** If `true` will remove any cache references on the associated {@link CacheRetainer|retainer}. */
|
||||
preventReleases?: boolean;
|
||||
/** Caller's logger */
|
||||
logger?: Logger;
|
||||
}) {
|
||||
const { requestsToIgnore, preventReleases = false, logger } = options || {};
|
||||
|
||||
Log(`CacheManager:retainRequests ${requests.length}\n\tRetainer: ${retainerPath}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "updateRetainedCaches");
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retainer: ", retainerPath);
|
||||
Env.dev.runDev(() => {
|
||||
requests.forEach(request => Env.assert(request.requesterPath === retainerPath, `Expected retainer of cache request (${request.source}) equal to ${retainerPath}`))
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `${requests.length} retain requests:`);
|
||||
requests.forEach(request => Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t\t", request.source));
|
||||
});
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `To ignore count: ${requestsToIgnore ? requestsToIgnore.size : 0}, preventCacheRelases: ${preventReleases}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Current retain count: ", this.retainCount());
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[retainerPath];
|
||||
|
||||
const oldCi = retainer ? [...retainer.ref] : [];
|
||||
const newCi: string[] = [];
|
||||
// If retainer/file does not exist / is not yet registered, it will be.
|
||||
const retainer = this.metadataRoot.retainers[retainerPath];
|
||||
const cacheKeysCurrentlyReferenced = retainer ? [...retainer.ref] : [];
|
||||
|
||||
// Populate set of keys to retain.
|
||||
// Make sure duplicates are removed and do not include cache keys that don't exist.
|
||||
const cacheKeysRequestedReferenced: string[] = [];
|
||||
for (const request of requests) {
|
||||
const cacheKey = CacheManager.createCacheKeyFromRequest(request);
|
||||
if (!newCi.includes(cacheKey)) {
|
||||
newCi.push(cacheKey);
|
||||
const cacheMetadata = this.cache[cacheKey];
|
||||
if (cacheMetadata !== undefined) {
|
||||
if (!cacheKeysRequestedReferenced.includes(cacheKey))
|
||||
cacheKeysRequestedReferenced.push(cacheKey);
|
||||
}
|
||||
else {
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\tCache key does not (yet) exist:", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
const addedReferences = newCi.filter(key => !oldCi.includes(key));
|
||||
const removedReferences = oldCi.filter(key => !newCi.includes(key));
|
||||
// These keys will be removed from the retain and release arrays below.
|
||||
const ignoredReferences = requestsToIgnore
|
||||
? [...requestsToIgnore].map(r => CacheManager.createCacheKeyFromRequest(r))
|
||||
: [];
|
||||
|
||||
// New cache keys not previously referenced/retained by the retainer.
|
||||
const addedReferences = cacheKeysRequestedReferenced
|
||||
.filter(key => !cacheKeysCurrentlyReferenced.includes(key))
|
||||
.filter(key => !ignoredReferences.includes(key));
|
||||
|
||||
// Cache keys previously referenced/retained by the retainer which will now be released.
|
||||
const removedReferences = preventReleases ? [] : cacheKeysCurrentlyReferenced
|
||||
.filter(key => !cacheKeysRequestedReferenced.includes(key))
|
||||
.filter(key => !ignoredReferences.includes(key));
|
||||
|
||||
if (addedReferences.length == 0 && removedReferences.length == 0) {
|
||||
Log(`\tNo change.`)
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `No change: all requested cache keys match the already referenced/retained keys. Aborting.`)
|
||||
return;
|
||||
}
|
||||
|
||||
// Input validation: Prevent removeal cache items that are not referenced by the retainer. This should not happen.
|
||||
const invalidRemovedReferences = removedReferences.filter(key => !cacheKeysCurrentlyReferenced.includes(key));
|
||||
const validRemovedReferences = removedReferences.filter(key => !invalidRemovedReferences.includes(key));
|
||||
if (invalidRemovedReferences.length > 0)
|
||||
Env.log.e(`Attempted to remove the following cache references from retainer '${retainerPath}' which does not reference them ${logger?.idString ?? Env.str.EMPTY}:`, invalidRemovedReferences);
|
||||
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Requested cache keys:", cacheKeysRequestedReferenced);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retaining:", addedReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Releasing:", validRemovedReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Ignoring:", ignoredReferences);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Not releasing (invalid):", invalidRemovedReferences);
|
||||
|
||||
let newRef = retainer !== undefined ? [...retainer.ref, ...addedReferences] : addedReferences;
|
||||
newRef = newRef.filter(r => !validRemovedReferences.includes(r));
|
||||
this.setRetainerRefs(retainerPath, newRef);
|
||||
|
||||
const updatedRetainCount = this.retainCount();
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Updated retain count: ", updatedRetainCount);
|
||||
|
||||
// Remove caches that are no longer referenced by any retainer.
|
||||
for (const removedReference of validRemovedReferences) {
|
||||
if (!this.isRetained(updatedRetainCount, removedReference))
|
||||
await this.removeCacheItem(removedReference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites {@link CacheRetainer#ref}.
|
||||
*
|
||||
* - Method is not concerned with reference counting.
|
||||
* - Call {@link saveMetadataIfDirty} to persist changes.
|
||||
*
|
||||
* @param retainerPath A new {@link CacheRetainer} will be created and assigned {@link ref} if non exists with this path.
|
||||
* @param ref May contain duplicates as they will be removed. If empty, {@link CacheRetainer} will be removed.
|
||||
*/
|
||||
public setRetainerRefs(retainerPath: string, ref: string[]) {
|
||||
|
||||
// - Even though it's an array, a key should not appear twice (even if the associated physical cache is used more than once in the note).
|
||||
// - Do not reference non-existent keys.
|
||||
const uniqueAndExistingCacheKeys = [...new Set(ref)].filter(cacheKey => {
|
||||
const filter = Object.hasOwn(this.cache, cacheKey);
|
||||
Env.assert(filter, "Invalid reference to non-existent cache key ignored:", cacheKey);
|
||||
return filter;
|
||||
});
|
||||
|
||||
Env.log.d("CacheManager:setRetainerRefs:", retainerPath, uniqueAndExistingCacheKeys);
|
||||
|
||||
this.isMetadataDirty = true;
|
||||
|
||||
// console.log(`Update: ` + newCi);
|
||||
// console.log(`Retaining: ` + addedReferences);
|
||||
// console.log(`Releasing: ` + removedReferences);
|
||||
|
||||
if (retainer) {
|
||||
retainer.ref = newCi;
|
||||
let retainer = this.metadataRoot.retainers[retainerPath];
|
||||
if (retainer !== undefined) {
|
||||
retainer.ref = uniqueAndExistingCacheKeys;
|
||||
}
|
||||
else {
|
||||
retainer = { ref: newCi }
|
||||
// Create retainer
|
||||
retainer = { ref: uniqueAndExistingCacheKeys };
|
||||
this.metadataRoot.retainers[retainerPath] = retainer;
|
||||
}
|
||||
|
||||
const retainCount = this.retainCount();
|
||||
// console.log(this.retainCount());
|
||||
|
||||
for (const removedReference of removedReferences) {
|
||||
const cacheKeyRetainCount = retainCount[removedReference];
|
||||
console.assert(cacheKeyRetainCount !== undefined, `Expected key ${removedReference} in retain count record.`);
|
||||
if (cacheKeyRetainCount === 0) {
|
||||
await this.removeCacheItem(removedReference);
|
||||
}
|
||||
}
|
||||
|
||||
// If it doesn't refernce anything anymore there's no need to keep it around.
|
||||
// As of now, this is the only property of a CacheRetainer so it can be cleaned up if property is empty.
|
||||
if (retainer.ref.length == 0)
|
||||
delete this.metadataRoot.retainers[retainerPath];
|
||||
}
|
||||
|
||||
public renameRetainer(oldPath: string, path: string) {
|
||||
Log(`CacheManager.renameRetainer\n\t${oldPath}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager.renameRetainer\n\t${oldPath}`);
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[oldPath];
|
||||
if (retainer) {
|
||||
const retainer = this.metadataRoot.retainers[oldPath];
|
||||
if (retainer !== undefined) {
|
||||
this.metadataRoot.retainers[path] = retainer;
|
||||
delete this.metadataRoot.retainers[oldPath];
|
||||
this.isMetadataDirty = true;
|
||||
}
|
||||
else {
|
||||
Env.log.cm("\tFailed: retainer not found.", oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeRetainer(path: string) {
|
||||
Log(`CacheManager.removeRetainer\n\t${path}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager.removeRetainer\n\t${path}`);
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[path];
|
||||
if (!retainer) {
|
||||
const retainer = this.metadataRoot.retainers[path];
|
||||
if (retainer === undefined) {
|
||||
// This can happen if a file which hasn't been open was deleted without opening it.
|
||||
Env.log.cm("\tFailed: No retainer found.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Make sure caches that become unreferenced when this retainer is removed are also removed.
|
||||
const retainCount = this.retainCount(retainer)
|
||||
for (const cacheKey of retainer.ref) {
|
||||
// Release
|
||||
retainCount[cacheKey]--;
|
||||
// Note: make sure to `if (count !== undefined)` rather than `if(count)` otherwise `count` will not be treated as a `number` and subtraction will fail.
|
||||
let count = retainCount[cacheKey];
|
||||
Env.assert(count !== undefined, cacheKey);
|
||||
|
||||
if (retainCount[cacheKey] == 0) {
|
||||
await this.removeCacheItem(cacheKey);
|
||||
if (count !== undefined) {
|
||||
count = count - 1;
|
||||
retainCount[cacheKey] = count;
|
||||
|
||||
if (count == 0)
|
||||
await this.removeCacheItem(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
delete this.metadataRoot.retainers[path];
|
||||
this.isMetadataDirty = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Env.log.e(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link cacheKey} is found in metadata, will remove the associated file from cache and from the metadata.
|
||||
*
|
||||
* {@link saveMetadataIfDirty} needs to be called at some point to persist the matadata.
|
||||
*
|
||||
* @param cacheKey
|
||||
*/
|
||||
private async removeCacheItem(cacheKey: string) {
|
||||
const metadata = this.cache[cacheKey];
|
||||
console.assert(metadata !== undefined, `Attempted to remove cash item using a non-existing key.`);
|
||||
Env.assert(metadata !== undefined, `Attempted to remove cash item using a non-existing key.`);
|
||||
if (metadata !== undefined) {
|
||||
Log(`CacheManager:removeCacheItem\n\tRemoving ${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "removeCacheItem", "\n\tRemoving", this.nameOfCachedFileFromMetadata(metadata, cacheKey));
|
||||
await this.vault.adapter.remove(this.filePathToCachedFileFromMetadata(metadata, cacheKey));
|
||||
delete this.cache[cacheKey];
|
||||
this.isMetadataDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -343,28 +428,51 @@ export class CacheManager {
|
|||
}
|
||||
|
||||
for (const retainer of Object.values(this.metadataRoot.retainers)) {
|
||||
for (const cacheKey of retainer.ref)
|
||||
if (cacheKey in retainCounts)
|
||||
retainCounts[cacheKey]++;
|
||||
// Increase count for each reference found.
|
||||
for (const cacheKey of retainer.ref) {
|
||||
// Note: make sure to `if (count !== undefined)` rather than `if(count)` otherwise `count` will not be treated as a `number` and addition will fail.
|
||||
const count = retainCounts[cacheKey];
|
||||
Env.assert(count !== undefined, "Retainer is referencing a cache key that does not exist", cacheKey);
|
||||
if (count !== undefined)
|
||||
retainCounts[cacheKey] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete zero counts for consistency: keys that do not exist are not referenced. Showing zero counts is also confusing when log-debugging.
|
||||
for (const key in retainCounts) {
|
||||
if (retainCounts[key] === 0)
|
||||
delete retainCounts[key];
|
||||
}
|
||||
|
||||
return retainCounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param retainCounts Result of calling {@link retainCount}.
|
||||
* @param key Cache key to check.
|
||||
* @returns `true` if the key is retained in {@link retainCounts}.
|
||||
*/
|
||||
private isRetained(retainCounts: Record<string, number>, key: string) {
|
||||
const count = retainCounts[key];
|
||||
return count === undefined || count === 0 ? false : true; // Since 1.0.6, retain counts of zero are actually not present in the dictionary anymore.
|
||||
}
|
||||
|
||||
public async info(callback: (info: CacheInfo) => void) {
|
||||
Env.log.d("CacheManager:info");
|
||||
|
||||
const cacheKeys = Object.keys(this.cache);
|
||||
|
||||
// Actual files on disk
|
||||
const actualCachedFilePaths = await this.actualCachedFilePaths();
|
||||
//Env.log.d("\tPhysical files in cache folder:", actualCachedFilePaths);
|
||||
|
||||
// There is a cache key but no file with the same name.
|
||||
const cacheKeysWithoutAssociatedFile = [];
|
||||
for (const cacheKey of cacheKeys) {
|
||||
const cacheKeyMetadata: CacheMetadata = this.cache[cacheKey];
|
||||
const cacheKeyMetadata = this.cache[cacheKey];
|
||||
let found = false;
|
||||
for (const associatedFilePath of actualCachedFilePaths) {
|
||||
if (associatedFilePath === this.filePathToCachedFileFromMetadata(cacheKeyMetadata, cacheKey)) {
|
||||
if (cacheKeyMetadata !== undefined && associatedFilePath === this.filePathToCachedFileFromMetadata(cacheKeyMetadata, cacheKey)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -378,8 +486,8 @@ export class CacheManager {
|
|||
for (const actualFilePath of actualCachedFilePaths) {
|
||||
let found = false;
|
||||
for (const cacheKey of cacheKeys) {
|
||||
const cacheKeyMetadata: CacheMetadata = this.cache[cacheKey];
|
||||
if (actualFilePath === this.filePathToCachedFileFromMetadata(cacheKeyMetadata, cacheKey)) {
|
||||
const cacheKeyMetadata = this.cache[cacheKey];
|
||||
if (cacheKeyMetadata && actualFilePath === this.filePathToCachedFileFromMetadata(cacheKeyMetadata, cacheKey)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -390,7 +498,7 @@ export class CacheManager {
|
|||
|
||||
//
|
||||
const retainers: CacheRetainer[] = Object.values(this.metadataRoot.retainers);
|
||||
const numberOfRetainers = retainers.length;
|
||||
//const numberOfRetainers = retainers.length;
|
||||
|
||||
// Here the Mardown file has been deleted but its still exists as a retainer.
|
||||
const retainersWithoutActualFile = [];
|
||||
|
|
@ -401,7 +509,7 @@ export class CacheManager {
|
|||
|
||||
const retainersWithoutReferences = [];
|
||||
for (const retainer of retainers) {
|
||||
if (!retainer.ref || retainer.ref.length == 0)
|
||||
if (!Array.isArray(retainer.ref) || retainer.ref.length === 0)
|
||||
retainersWithoutReferences.push(retainer);
|
||||
}
|
||||
|
||||
|
|
@ -411,11 +519,7 @@ export class CacheManager {
|
|||
const retainCount = this.retainCount();
|
||||
|
||||
for (const cacheKey of cacheKeys) {
|
||||
if (cacheKey in retainCount) {
|
||||
if (retainCount[cacheKey] == 0) // If the retain count is zero there is no retainer referencing it.
|
||||
cacheKeysWithoutAnyRetainer.push(cacheKey);
|
||||
}
|
||||
else // If there's a cache key that's not found in the retain count record, it means that there's no retainer that references the cache key.
|
||||
if (!this.isRetained(retainCount, cacheKey))
|
||||
cacheKeysWithoutAnyRetainer.push(cacheKey);
|
||||
}
|
||||
|
||||
|
|
@ -437,8 +541,6 @@ export class CacheManager {
|
|||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Get {@link CacheMetadata} if in cache.
|
||||
* Will do nothing if the request doesn't exist in the cache.
|
||||
|
|
@ -462,6 +564,10 @@ export class CacheManager {
|
|||
return new CacheResult(request, cacheKey, null, new CacheNotFoundError(cacheKey), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks `vault.adapter.getResourcePath` adds (what looks like) a timestamp parameter to the end of the {@link CacheItem.resourcePath},
|
||||
* e.g., "…/dbc4b9faa6ffacd2.png?1759472349631".
|
||||
*/
|
||||
private createCacheItem(metadata: CacheMetadata, fromCache: boolean): CacheItem {
|
||||
return {
|
||||
resourcePath: this.vault.adapter.getResourcePath(this.filePathToCachedFileFromMetadata(metadata)),
|
||||
|
|
@ -502,13 +608,13 @@ export class CacheManager {
|
|||
|
||||
// TODO: If assuming that, if the requested item is downloading, it doesn't exist on disk, then we could return null here. But can also proceed anyway.
|
||||
// if (this.cacheRequests.get(cacheID)) {
|
||||
// Log(`CacheManager:existingCachedItem: Aborting because cache is currently downloading.`)
|
||||
// Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:existingCachedItem: Aborting because cache is currently downloading.`)
|
||||
// return null;
|
||||
// }
|
||||
|
||||
const result = await this.existingCache(request);
|
||||
if (result.item) {
|
||||
Log(`CacheManager:getCache\n\tGot cache for cacheKey: ${result.cacheKey}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:getCache\n\tGot cache for cacheKey: ${result.cacheKey}`);
|
||||
callback(result);
|
||||
}
|
||||
else {
|
||||
|
|
@ -536,7 +642,7 @@ export class CacheManager {
|
|||
result = await this.download(request, sourceFileInfo);
|
||||
}
|
||||
catch (error) {
|
||||
result = new CacheResult(request, cacheKey ?? "", null, error);
|
||||
result = new CacheResult(request, cacheKey ?? Env.str.EMPTY, null, Err.toError(error));
|
||||
}
|
||||
finally {
|
||||
if (cacheKey)
|
||||
|
|
@ -564,7 +670,7 @@ export class CacheManager {
|
|||
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
callback?.(Err.toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -590,7 +696,7 @@ export class CacheManager {
|
|||
this.metadataRoot = Object.assign({}, EMPTY_CACHE_ROOT, JSON.parse(metadataFileContent));
|
||||
await this.updateMetadataFileLastModified();
|
||||
} catch (error) {
|
||||
console.error("Failed to read metadata. Clearing cache.", error);
|
||||
Env.log.e("Failed to read metadata. Clearing cache.", error);
|
||||
await this.clearCached();
|
||||
}
|
||||
}
|
||||
|
|
@ -609,18 +715,20 @@ export class CacheManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* @throws {Error} If writing to the storage fails.
|
||||
*/
|
||||
* Always saves. See {@link saveMetadataIfDirty} to only save is metadata is dirty.
|
||||
*
|
||||
* @throws {Error} If writing to the storage fails.
|
||||
*/
|
||||
private async saveMetadata() {
|
||||
Log(`CacheManager:saveMetadata`)
|
||||
await this.vault.adapter.write(this.metadataFilePath, ENV.dev ? JSON.stringify(this.metadataRoot, null, 2) : JSON.stringify(this.metadataRoot));
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:saveMetadata`)
|
||||
await this.vault.adapter.write(this.metadataFilePath, Env.isDev ? JSON.stringify(this.metadataRoot, null, 2) : JSON.stringify(this.metadataRoot));
|
||||
this.isMetadataDirty = false;
|
||||
await this.updateMetadataFileLastModified();
|
||||
this.invokeMetadataChangeListeners();
|
||||
}
|
||||
|
||||
public saveMetadataIfDirty(): Promise<void> | null {
|
||||
Log(`CacheManager:saveMetadataIfDirty ${this.isMetadataDirty}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:saveMetadataIfDirty ${this.isMetadataDirty}`);
|
||||
return this.isMetadataDirty ? this.saveMetadata() : null;
|
||||
}
|
||||
private isMetadataDirty: boolean = false;
|
||||
|
|
@ -644,9 +752,10 @@ export class CacheManager {
|
|||
* Should be called when the cache metadata file changed externally so that the in-memory copy is reloaded.
|
||||
* Does nothing if the file actually didn't change.
|
||||
*/
|
||||
public async onMetadataFileChangedExternally() {
|
||||
public async checkIfMetadataFileChangedExternally() {
|
||||
const current = await CacheManager.fileLastModified(this.vault, this.metadataFilePath);
|
||||
if (current && current > this.metadataFileLastModified) {
|
||||
Env.log.d(Env.dev.thunkedStr(() => `CacheManager:onMetadataFileChangedExternally: Will refresh in-memory metadata: ${current !== null && current > this.metadataFileLastModified}`));
|
||||
if (current !== null && current > this.metadataFileLastModified) {
|
||||
await this.loadMetadata();
|
||||
this.invokeMetadataChangeListeners();
|
||||
return true;
|
||||
|
|
@ -660,7 +769,7 @@ export class CacheManager {
|
|||
callback(this.metadataRoot);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error executing data changed callback:", error);
|
||||
Env.log.e("Error executing data changed callback:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -679,7 +788,7 @@ export class CacheManager {
|
|||
* Aborts current download requests for the specified file.
|
||||
* @todo
|
||||
*/
|
||||
async cancelOngoing(filePath: string) {
|
||||
async cancelOngoing(_filePath: string) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
|
|
@ -703,6 +812,23 @@ export class CacheManager {
|
|||
return CacheManager.hasher.h64Raw(bytes).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
public static createRequest(src: string, path: string, isReadOnly: boolean = false): CacheRequest {
|
||||
const source = src.trim();
|
||||
Env.assert(source.length > 0);
|
||||
const requesterPath = path.trim();
|
||||
Env.assert(isReadOnly || requesterPath.length > 0);
|
||||
return { source, requesterPath, isReadOnly: isReadOnly } satisfies CacheRequest;
|
||||
}
|
||||
|
||||
/** @todo Also see {@link CacheRequest#requesterPath}. */
|
||||
public static createReadOnlyRequest(src: string): CacheRequest {
|
||||
return CacheManager.createRequest(src, Env.str.EMPTY, true);
|
||||
}
|
||||
|
||||
public static isRequestEqual(request: CacheRequest, otherRequest: CacheRequest) {
|
||||
return request.source === otherRequest.source && request.requesterPath === otherRequest.requesterPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a key from the {@link request}.
|
||||
* @param request
|
||||
|
|
@ -758,19 +884,22 @@ export class CacheManager {
|
|||
const cacheKey = CacheManager.createCacheKeyFromRequest(request);
|
||||
|
||||
try {
|
||||
Log(`CacheManager:download: Requesting ${cacheKey} ⬇️⬇️⬇️\n\t...${sourceUrl.slice(-50)}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:download: Requesting ${cacheKey} ⬇️⬇️⬇️\n\t...${sourceUrl.slice(-50)}`);
|
||||
|
||||
if (ENV.dev)
|
||||
if (Env.isDev)
|
||||
await sleep(Math.floor(Math.random() * 1001) + 1000);
|
||||
|
||||
const response = await requestUrl({ url: sourceUrl, method: 'GET', throw: false });
|
||||
const headers = Url.normalizeHeaders(response.headers);
|
||||
const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType] ?? undefined;
|
||||
const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl] ?? undefined;
|
||||
const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType];
|
||||
const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl];
|
||||
const etag = Url.parseETag(headers[Url.RESPONSE_HEADER_LOWERCASE.etag]);
|
||||
const lastModifiedHeader = headers[Url.RESPONSE_HEADER_LOWERCASE.lastModified];
|
||||
const lastModified = lastModifiedHeader !== undefined ? new Date(lastModifiedHeader).toISOString() : undefined;
|
||||
|
||||
//Log(`CacheManager:download: Got response:\n\tcacheID: ${cacheKey}\n\t${response.status}\n\tcontentType: ${contentType}`);
|
||||
//Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:download: Got response:\n\tcacheID: ${cacheKey}\n\t${response.status}\n\tcontentType: ${contentType}`);
|
||||
|
||||
if (cacheControl == Url.CACHE_CONTROL_LOWERCASE.noStore)
|
||||
if (cacheControl === Url.CACHE_CONTROL_LOWERCASE.noStore)
|
||||
throw new CacheError(`Caching not allowed on ${sourceUrl}.`, cacheKey);
|
||||
|
||||
if (response.status >= 400)
|
||||
|
|
@ -788,8 +917,8 @@ export class CacheManager {
|
|||
try {
|
||||
await this.vault.adapter.remove(cacheItemPath);
|
||||
} catch (removeError) {
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to remove cached file after write error:", removeError);
|
||||
if (Env.isDev)
|
||||
Env.log.e("Failed to remove cached file after write error:", removeError);
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
|
|
@ -800,6 +929,8 @@ export class CacheManager {
|
|||
d: nowDateString,
|
||||
l: nowDateString,
|
||||
cc: cacheControl,
|
||||
et: etag !== null ? etag : undefined,
|
||||
m: lastModified,
|
||||
},
|
||||
f: {
|
||||
s: sourceUrl,
|
||||
|
|
@ -810,18 +941,18 @@ export class CacheManager {
|
|||
ch: CacheManager.hashBinary(bytes),
|
||||
},
|
||||
i: imageMetadata
|
||||
};
|
||||
} satisfies CacheMetadata;
|
||||
|
||||
try {
|
||||
this.metadataRoot.items[cacheKey] = metadata
|
||||
await this.saveMetadata(); // Throws
|
||||
result = new CacheResult(request, cacheKey, this.createCacheItem(metadata, false), null, true);
|
||||
|
||||
Log(`CacheManager:download\n\tDownloaded and cached ${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
|
||||
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:download\n\tDownloaded and cached ${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
|
||||
} catch (error) {
|
||||
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to write cache metadata:", error);
|
||||
if (Env.isDev)
|
||||
Env.log.e("Failed to write cache metadata:", error);
|
||||
|
||||
// File write failed. Rollback the cache and remove the potentially created file.
|
||||
delete this.cache[cacheKey];
|
||||
|
|
@ -829,8 +960,8 @@ export class CacheManager {
|
|||
try {
|
||||
await this.vault.adapter.remove(cacheItemPath);
|
||||
} catch (removeError) {
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to remove cached file after write error:", removeError);
|
||||
if (Env.isDev)
|
||||
Env.log.e("Failed to remove cached file after write error:", removeError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
|
@ -839,7 +970,7 @@ export class CacheManager {
|
|||
if (error instanceof CacheError)
|
||||
result = new CacheResult(request, cacheKey, null, error);
|
||||
else
|
||||
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, error, { sourceUrl: sourceUrl }));
|
||||
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, Err.toError(error), { sourceUrl: sourceUrl }));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -862,7 +993,7 @@ export class CacheManager {
|
|||
return {
|
||||
w: sizeCalcResult.width,
|
||||
h: sizeCalcResult.height,
|
||||
t: sizeCalcResult?.type ?? "",
|
||||
t: sizeCalcResult.type ?? Env.str.EMPTY,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
|
|
@ -75,9 +75,32 @@ export interface CacheMetadataTime {
|
|||
/** Download time */
|
||||
d: string;
|
||||
|
||||
/** Last accessed */
|
||||
/**
|
||||
* "Last time checked".
|
||||
*
|
||||
* Currently set to download time and never updated.
|
||||
* @todo May be used to keep track of when the server was last checked for modified.
|
||||
*/
|
||||
l: string;
|
||||
|
||||
/** Value of the `Cache-Control` HTTP response header. */
|
||||
cc?: string;
|
||||
|
||||
/**
|
||||
* Value of the `ETag` HTTP response header.
|
||||
*
|
||||
* In the HTTP spec (RFC 7232), an ETag must be wrapped in double quotes. But here they are not. Add them back when using the ETag in requests.
|
||||
*
|
||||
* @since 1.1.1
|
||||
*/
|
||||
et?: string;
|
||||
|
||||
/**
|
||||
* Normalized value of the `Last-Modified` HTTP response header (ISO 8601).
|
||||
*
|
||||
* Converted from HTTP-date format to ISO for consistency. Convert back to UTC string when using in 'If-Modified-Since' headers.
|
||||
*
|
||||
* @since 1.1.1
|
||||
*/
|
||||
m?: string;
|
||||
}
|
||||
599
src/main.ts
599
src/main.ts
|
|
@ -1,13 +1,17 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Plugin, MarkdownPostProcessorContext, normalizePath, TFile } from "obsidian";
|
||||
import { SettingTab, SettingsManager, PluginSettings } from "Settings";
|
||||
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "CacheManager";
|
||||
import { Log, Notice, ENV, clearBrowserCache } from "Environment";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "HtmlAssistant";
|
||||
import { InfoModal } from "InfoModal";
|
||||
import { ProcessingPass } from "ProcessingPass";
|
||||
import { Url } from "Url";
|
||||
import { Workarounds } from "Workarounds";
|
||||
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
|
||||
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { MarkdownPostProcessorContext, Plugin, TFile, normalizePath } from "obsidian";
|
||||
import { EditorViewPlugin, EditorViewPluginInfo } from "processing/EditorViewPlugin";
|
||||
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "processing/HtmlAssistant";
|
||||
import { ProcessingContext } from "processing/ProcessingContext";
|
||||
import { ProcessingPass } from "processing/ProcessingPass";
|
||||
import { PluginSettings, SettingTab, SettingsManager } from "Settings";
|
||||
import { InfoModal } from "ui/InfoModal";
|
||||
import { Notice } from "ui/Notice";
|
||||
import { queueAsyncMicrotask, sleep } from "utils/dom";
|
||||
import { File } from "utils/File";
|
||||
|
||||
|
||||
interface PluginData {
|
||||
settings: PluginSettings;
|
||||
|
|
@ -18,15 +22,12 @@ const DEFAULT_DATA: PluginData = {
|
|||
} as const;
|
||||
|
||||
export default class ComeDownPlugin extends Plugin {
|
||||
private data: PluginData;
|
||||
private settingsManager: SettingsManager;
|
||||
private cacheManager: CacheManager;
|
||||
private data!: PluginData;
|
||||
private settingsManager!: SettingsManager;
|
||||
private cacheManager!: CacheManager;
|
||||
|
||||
async onload() {
|
||||
|
||||
//#region Init
|
||||
|
||||
//const startTime = performance.now();
|
||||
override async onload() {
|
||||
Env.log.d("Plugin:onload");
|
||||
|
||||
Notice.setName(this.manifest.name);
|
||||
|
||||
|
|
@ -47,14 +48,13 @@ export default class ComeDownPlugin extends Plugin {
|
|||
);
|
||||
this.addSettingTab(new SettingTab(this, this.settingsManager, this.cacheManager));
|
||||
|
||||
//console.log(`ComeDown: init: ${performance.now() - startTime} milliseconds`);
|
||||
const viewPlugin = ViewPlugin.fromClass(EditorViewPlugin);
|
||||
this.registerEditorExtension([
|
||||
viewPlugin,
|
||||
EditorView.updateListener.of((update) => update.view.plugin(viewPlugin)?.postUpdate(update, this.editorViewUpdateListener.bind(this)))
|
||||
]);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Register
|
||||
|
||||
this.registerMarkdownPostProcessor((e, c) => this.postProcessReadingModeHtml(e, c));
|
||||
this.registerEditorExtension(EditorView.updateListener.of((vu) => this.editorViewUpdateListener(vu)));
|
||||
this.registerMarkdownPostProcessor((e, c) => this.markdownPostProcessor(e, c));
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
|
|
@ -73,70 +73,69 @@ export default class ComeDownPlugin extends Plugin {
|
|||
})
|
||||
);
|
||||
|
||||
this.registerInterval(
|
||||
window.setInterval(() => this.cacheManager.onMetadataFileChangedExternally().catch(console.error),
|
||||
5000
|
||||
));
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
setTimeout(() => this.removeSyncConflictFiles(), 1000);
|
||||
this.registerInterval(window.setInterval(() => this.cacheManager.checkIfMetadataFileChangedExternally().catch(Env.log.e), 1000 * 60 * 10));
|
||||
|
||||
if (ENV.dev) {
|
||||
|
||||
this.addCommand({
|
||||
id: "open-info-modal",
|
||||
name: "Cacheboard",
|
||||
callback: () => {
|
||||
new InfoModal(this.app, this.cacheManager, this.settingsManager.settings).open();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "delete-all-cache-and-reload",
|
||||
name: "Delete cache and reload",
|
||||
callback: () => {
|
||||
this.cacheManager.clearCached((error) => {
|
||||
if (error)
|
||||
console.error("Failed to clear cache", error);
|
||||
else
|
||||
clearBrowserCache(this.app);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
if (Env.isDev) {
|
||||
this.addCommand({
|
||||
id: "open-info-modal",
|
||||
name: "Cacheboard",
|
||||
callback: () => {
|
||||
this.cacheManager.checkIfMetadataFileChangedExternally()
|
||||
.then(() => new InfoModal(this.app, this.cacheManager, this.settingsManager.settings).open())
|
||||
.catch(Env.log.e);
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "delete-all-cache-and-reload",
|
||||
name: "Delete cache and reload",
|
||||
callback: () => {
|
||||
this.cacheManager.clearCached((error) => {
|
||||
if (error)
|
||||
Env.log.e("Failed to clear cache", error);
|
||||
else
|
||||
Env.clearBrowserCache(this.app);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
await this.cacheManager?.cancelAllOngoing();
|
||||
override async onunload() {
|
||||
Env.log.d("Plugin:onunload");
|
||||
await this.cacheManager.cancelAllOngoing();
|
||||
}
|
||||
|
||||
onExternalSettingsChange?() {
|
||||
if (this.data) {
|
||||
ComeDownPlugin.loadPluginData(this).then(data => {
|
||||
this.data = data;
|
||||
this.settingsManager.onSettingsChangedExternally(this.data.settings);
|
||||
})
|
||||
}
|
||||
override onExternalSettingsChange() {
|
||||
Env.log.d("Plugin:onExternalSettingsChange");
|
||||
|
||||
ComeDownPlugin.loadPluginData(this).then(data => {
|
||||
this.data = data;
|
||||
this.settingsManager.onSettingsChangedExternally(this.data.settings);
|
||||
setTimeout(() => this.cacheManager.checkIfMetadataFileChangedExternally(), 1000);
|
||||
})
|
||||
}
|
||||
|
||||
private static async loadPluginData(plugin: Plugin): Promise<PluginData> {
|
||||
Env.log.d("Plugin:loadPluginData");
|
||||
const data = await plugin.loadData(); // Returns `null` if file doesn't exist.
|
||||
return Object.assign({}, DEFAULT_DATA, data);
|
||||
}
|
||||
|
||||
//#region Cache Init
|
||||
|
||||
/**
|
||||
* Will be set once it's ensured that `this.manifest.dir` is set.
|
||||
* @throws {Error} If `this.manifest.dir` isn't set.
|
||||
*/
|
||||
* Will be set once it's ensured that `this.manifest.dir` is set.
|
||||
* @throws {Error} If `this.manifest.dir` isn't set.
|
||||
*/
|
||||
get cacheDir(): string {
|
||||
if (this.cacheDirBacking === undefined) {
|
||||
const pluginDir = this.manifest.dir;
|
||||
if (pluginDir) {
|
||||
this.cacheDirBacking = normalizePath(`${pluginDir}/cache`);
|
||||
this.gitIgnorePath = normalizePath(`${this.cacheDirBacking}/.gitignore`);
|
||||
this.cacheMetadataPath = normalizePath(`${pluginDir}/cache.json`);
|
||||
this.cacheMetadataPath = normalizePath(pluginDir + "/" + PluginFile.Dynamic.CACHE.NAME + PluginFile.Dynamic.CACHE.EXT);
|
||||
}
|
||||
else {
|
||||
const errorMsg = `Cannot load because plugin directory is unknown`;
|
||||
|
|
@ -146,24 +145,22 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
return this.cacheDirBacking;
|
||||
}
|
||||
private cacheDirBacking?: string;
|
||||
private gitIgnorePath: string;
|
||||
private cacheMetadataPath: string;
|
||||
private cacheDirBacking?: string = undefined;
|
||||
private gitIgnorePath: string = Env.str.EMPTY;
|
||||
private cacheMetadataPath: string = Env.str.EMPTY;
|
||||
|
||||
/**
|
||||
* Ensures the cache directory exists.
|
||||
*
|
||||
* Do not catch {@link Error}s so as to prevent the plugin from being enabled in such cases.
|
||||
*/
|
||||
* Ensures the cache directory exists.
|
||||
*
|
||||
* Do not catch {@link Error}s so as to prevent the plugin from being enabled in such cases.
|
||||
*/
|
||||
async ensureCacheDir() {
|
||||
const cacheFolderExists = await this.app.vault.adapter.exists(this.cacheDir);
|
||||
if (!cacheFolderExists)
|
||||
await this.app.vault.adapter.mkdir(this.cacheDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure a `.gitignore` file is added/removed.
|
||||
*/
|
||||
/** Make sure a `.gitignore` file is added/removed. */
|
||||
async ensureGitIgnore() {
|
||||
const ensureGitIgnore = this.data.settings.gitIgnoreCacheDir;
|
||||
const gitignoreExists = await this.app.vault.adapter.exists(this.gitIgnorePath);
|
||||
|
|
@ -173,153 +170,231 @@ export default class ComeDownPlugin extends Plugin {
|
|||
await this.app.vault.adapter.remove(this.gitIgnorePath);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Processing
|
||||
|
||||
/**
|
||||
* Remove requesting, downloading, done, and invalid.
|
||||
* - The done image element is already pointing to the cached resource. No need to do anything more.
|
||||
* - Those that are downloading will be handled as the download finishes as part of a previous pass.
|
||||
*
|
||||
* Keep
|
||||
* - Original: these need to be cancelled
|
||||
* - Cancelled and Failed: these need to request cache.
|
||||
*
|
||||
* @param imageElement
|
||||
* @returns
|
||||
1. **Suffixes or Infixes Added to the Base Name**: This is the most common pattern. The original filename is preserved at ti inserted between them.
|
||||
- {filename} (conflicted copy) .{extension} (Dropbox)
|
||||
- {filename}-conflict-{timestamp}.{extension) (Syncthing)
|
||||
- {filename)_conflict-{timestamp}.{extension} (Nextcloud)
|
||||
2. **Suffixes Appended to the Full Filename**: In this pattern, the conflict marker is added after the original extension.
|
||||
- {filename}.{extension}.Conflict (Resilio Sync)
|
||||
- {filename}.{extension).orig (Git)
|
||||
3. **Prefixes Added to the Filename**: Some services, like Google Drive, might prepend text to the filename.
|
||||
- Copy of {filename). {extension)
|
||||
*/
|
||||
private filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [
|
||||
// HTMLElementCacheState.ORIGINAL,
|
||||
// HTMLElementCacheState.ORIGINAL_SRC_REMOVED,
|
||||
// HTMLElementCacheState.CACHE_FAILED
|
||||
// ]);
|
||||
|
||||
// Difference between this and the above is that ORIGINAL matches all unprocessed elements.
|
||||
return !HtmlAssistant.isCacheStateEqual(state, [
|
||||
HTMLElementCacheState.REQUESTING,
|
||||
HTMLElementCacheState.REQUESTING_DOWNLOADING,
|
||||
HTMLElementCacheState.CACHE_SUCCEEDED,
|
||||
HTMLElementCacheState.INVALID
|
||||
]);
|
||||
}
|
||||
|
||||
private editorViewUpdateListener(update: ViewUpdate) {
|
||||
|
||||
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update);
|
||||
|
||||
const processingPass = ProcessingPass.beginFromViewUpdate(this.app, update, () => {
|
||||
// There is no file to work with. All that can be done is to cancel loading.
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, true, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
|
||||
// Filter out image elements without a src or invalid.
|
||||
if (src === null || !Workarounds.HandleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// Allow image elements with external urls through so they can be cancelled.
|
||||
return Url.isValid(src) && Url.isExternal(src);
|
||||
});
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
});
|
||||
|
||||
if (!processingPass)
|
||||
private async removeSyncConflictFiles() {
|
||||
if (this.manifest.dir === undefined)
|
||||
return;
|
||||
|
||||
// Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements.
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, false, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
const doNotDeleteFilter = (path: string) => {
|
||||
const info = File.getPathInfo(path);
|
||||
if (info.filename === PluginFile.Static.MAIN || info.filename === PluginFile.Static.MANIFEST || info.filename === PluginFile.Static.STYLES)
|
||||
return false;
|
||||
else if (info.basename === PluginFile.Dynamic.DATA.NAME && info.extension === PluginFile.Dynamic.DATA.EXT)
|
||||
return false;
|
||||
else if (info.basename === PluginFile.Dynamic.CACHE.NAME && info.extension === PluginFile.Dynamic.CACHE.EXT)
|
||||
return false;
|
||||
else
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1. The user is editing the link (causing the source attribute to change) which resets the element's state.
|
||||
// External urls are only accepted in the element's original state.
|
||||
if (update.docChanged && src && Url.isExternal(src) && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL)
|
||||
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
|
||||
try {
|
||||
|
||||
// 2. As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained.
|
||||
if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
console.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
processingPass.retainCacheFromRequest({ source: src, requesterPath: processingPass.associatedFile.path });
|
||||
const removedFiles: string[] = [];
|
||||
const listed = await this.app.vault.adapter.list(this.manifest.dir);
|
||||
|
||||
for (const path of listed.files.filter(doNotDeleteFilter)) {
|
||||
const info = File.getPathInfo(path);
|
||||
const isDataConflictFile = info.basename.includes(PluginFile.Dynamic.DATA.NAME) && (info.extension.toLowerCase() === PluginFile.Dynamic.DATA.EXT || info.basename.includes(PluginFile.Dynamic.DATA.EXT));
|
||||
const isCacheConflictFile = info.basename.includes(PluginFile.Dynamic.CACHE.NAME) && (info.extension.toLowerCase() === PluginFile.Dynamic.CACHE.EXT || info.basename.includes(PluginFile.Dynamic.CACHE.EXT));
|
||||
|
||||
if (isDataConflictFile || isCacheConflictFile) {
|
||||
Env.log.i("Detected sync conflict file. Deleting:", info.filename);
|
||||
await this.app.vault.adapter.remove(path);
|
||||
removedFiles.push(info.filename);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.settingsManager.settings.noticeOnDeleteSyncConflictFile && removedFiles.length > 0)
|
||||
new Notice(`Found ${removedFiles.length} sync conflict files which were deleted.`, 0, false);
|
||||
|
||||
} catch (e) {
|
||||
Env.log.e(e);
|
||||
}
|
||||
}
|
||||
|
||||
private editorViewUpdateListener(update: ViewUpdate, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
|
||||
Env.log.d("Plugin:editorViewUpdateListener");
|
||||
|
||||
const l = ProcessingPass.createViewUpdateLogger();
|
||||
l.log(l.beginMsg("seq:", info.seqNum));
|
||||
|
||||
const ctx: ProcessingContext = ProcessingContext.fromViewUpdate(this.app, l, update);
|
||||
ProcessingContext.logInit(ctx);
|
||||
|
||||
if (ProcessingPass.abortIfInvalidContext(ctx, plugin, info))
|
||||
return;
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
|
||||
return;
|
||||
|
||||
// Find elements that needs to be cancelled, or if they already are cancelled, they need to be processed further, e.g. requesting cache.
|
||||
const { elementsToProcess } = ProcessingPass.findRelevantImagesToProcessViewUpdate(ctx);
|
||||
HtmlAssistant.cancelImageLoadIfNeeded(elementsToProcess);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "reader")) // If reader mode, just cancel then abort. Post processor will handle it.
|
||||
return;
|
||||
|
||||
// There are a few cases where a view update is called even though there are no actual updates. For example when switching a file in Obsidian.
|
||||
ctx.assertViewUpdateContext();
|
||||
if (!ctx.vuCtx.hasUpdates) {
|
||||
l.log(l.abortMsg("no updates"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort if there is nothing to do.
|
||||
if (elementsToProcess.length === 0 && !ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.abortMsg(l.t(() => `0 elements; focusChanged: ${update.focusChanged}; docChanged: ${update.docChanged}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for each pass to set states before next pass is allowed.
|
||||
queueAsyncMicrotask(async () => {
|
||||
l.log(l.msg("Running in serial queue. 🚶🏼🚶🏼🚶🏼"));
|
||||
|
||||
// Create a new context to reflect possible DOM changes.
|
||||
const pass = new ProcessingPass(ProcessingContext.fromViewUpdate(this.app, l, update));
|
||||
|
||||
if (Env.isDev && !pass.ctx.domEquals(ctx))
|
||||
ProcessingContext.logInit(pass.ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(pass.ctx, "source", "reader"))
|
||||
return;
|
||||
|
||||
// Read again to get the latest states (e.g. downloading).
|
||||
const {
|
||||
elementsToProcess,
|
||||
remainingElements
|
||||
} = ProcessingPass.findRelevantImagesToProcessViewUpdate(pass.ctx);
|
||||
|
||||
// Abort if there is nothing to do.
|
||||
if (elementsToProcess.length === 0 && !ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.abortMsg(l.t(() => `queue: 0 elements; focusChanged: ${update.focusChanged}; docChanged: ${update.docChanged}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. If there's no src there's nothing left to do but to remove all states that have passed this stage already.
|
||||
if (src === null)
|
||||
return this.filterIrrelevantCacheStates(imageElement);
|
||||
l.log(l.msg(l.t(() => `Found ${elementsToProcess.length} of ${elementsToProcess.length + remainingElements.length} images to process in current DOM.`)));
|
||||
|
||||
// 4. Only external urls are relevant.
|
||||
if (!(Url.isValid(src) && Url.isExternal(src)))
|
||||
return false;
|
||||
pass.handleRequestingAndSucceeded(remainingElements);
|
||||
pass.handleImagesNotInCurrentDOM([...elementsToProcess, ...remainingElements]);
|
||||
|
||||
// 5. Filter out invalid.
|
||||
if (!Workarounds.HandleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// 5. Remove all states that have passed this stage already.
|
||||
return this.filterIrrelevantCacheStates(imageElement);
|
||||
});
|
||||
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
// Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done.
|
||||
if (update.docChanged)
|
||||
processingPass.end(this.cacheManager);
|
||||
else
|
||||
processingPass.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Will not be called if never in Read mode since there's no need to render Markdown.
|
||||
* - In Read mode, it will always be called after the update listener, {@link editorViewUpdateListener}, as it might make changes.
|
||||
*
|
||||
* @param element A chunk of HTML.
|
||||
* @param context
|
||||
*/
|
||||
private postProcessReadingModeHtml(element: HTMLElement, context: MarkdownPostProcessorContext) {
|
||||
|
||||
const processingPass = ProcessingPass.beginFromPostProcessorContext(this.app, context);
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(element, true, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
return src !== null && Url.isValid(src) && Url.isExternal(src);
|
||||
// Even when there are no more images to display, the user might remove image embeds, which needs to be removed from the cache as well.
|
||||
if (elementsToProcess.length === 0 && ComeDownPlugin.checkRemovals(update)) {
|
||||
l.log(l.msg("Checking removals (no images to process but document changed)."));
|
||||
pass.end(this.cacheManager);
|
||||
}
|
||||
else {
|
||||
await this.requestCache(elementsToProcess, pass);
|
||||
}
|
||||
});
|
||||
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
processingPass.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @returns
|
||||
*/
|
||||
async requestCache(imageElements: HTMLImageElement[], processingPass: ProcessingPass) {
|
||||
* This could always return `true`. It is just used to avoid unnecessary processing.
|
||||
* - `focusChange`: if an image was removed externally, will be `true` when the note is opened, so that it its reference can be released.
|
||||
* - `docChanged`: user removes an image.
|
||||
*/
|
||||
private static checkRemovals = (update: ViewUpdate) => update.focusChanged || update.docChanged;
|
||||
|
||||
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, [HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED]));
|
||||
/**
|
||||
* @param element A chunk of HTML converted from markdown — not yet attached to the DOM.
|
||||
*/
|
||||
private markdownPostProcessor(element: HTMLElement, procContext: MarkdownPostProcessorContext) {
|
||||
Env.log.d("Plugin:markdownPostProcessor");
|
||||
|
||||
Log(`requestCache\n\tGot ${imageElements.length} <img> elements to populate.`);
|
||||
if (imageElements.length == 0)
|
||||
const l = ProcessingPass.createPostProcessorLogger();
|
||||
l.log(l.beginMsg());
|
||||
|
||||
const ctx = ProcessingContext.fromPostProcessor(this.app, l, element, procContext);
|
||||
ProcessingContext.logInit(ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
|
||||
return;
|
||||
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, processingPass);
|
||||
const { elementsToProcess } = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, true);
|
||||
HtmlAssistant.cancelImageLoadIfNeeded(elementsToProcess);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "preview")) // If preview mode, cancel then abort.
|
||||
return;
|
||||
|
||||
if (elementsToProcess.length === 0) {
|
||||
l.log(l.abortMsg("elementsToProcess:", elementsToProcess.length));
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for each pass to set states before next pass is allowed.
|
||||
queueAsyncMicrotask(async () => {
|
||||
l.log(l.msg("Running in serial queue. 🚶🏼🚶🏼🚶🏼"));
|
||||
|
||||
// By now, the HTML element chunk that was provided with this post processing callback
|
||||
// should have been attached to the DOM if it is visible or just outside the viewport.
|
||||
|
||||
// Wait for subsequent elements to be attached inorder to group more elements in to the same pass.
|
||||
// This is merely to reduce the number of download notices shown to the user.
|
||||
// There's a cap to how many elements can be attached because the viewport size is limited. 5ms seems to be enough, so 10.
|
||||
await sleep(10);
|
||||
|
||||
// Create a new context to reflect possible DOM changes after being queued and slept.
|
||||
const pass: ProcessingPass = new ProcessingPass(ProcessingContext.fromPostProcessor(this.app, l, element, procContext));
|
||||
pass.ctx.assertPostProcessor();
|
||||
|
||||
if (Env.isDev && !pass.ctx.domEquals(ctx))
|
||||
ProcessingContext.logInit(pass.ctx);
|
||||
|
||||
if (ProcessingPass.abortIfMode(pass.ctx, "source", "preview"))
|
||||
return;
|
||||
|
||||
const containerEl = pass.ctx.getPreferredContainerEl();
|
||||
|
||||
// Read again to get the latest states (e.g. downloading).
|
||||
const {
|
||||
elementsToProcess: elementsToProcessInDom,
|
||||
remainingElements
|
||||
} = ProcessingPass.findRelevantImagesToProcessInPostProcessor(containerEl, false);
|
||||
|
||||
// If the HTML element chunk, that was provided with this post processing callback,
|
||||
// is not yet attached to the DOM (at this point executing in the queue),
|
||||
// it needs to be included, as it could not have be found above.
|
||||
//
|
||||
// TODO: These could be collected and handled together similar to the ones already attached.
|
||||
let imagesToProcessInCurrentElement: HTMLImageElement[] = [];
|
||||
if (!pass.ctx.ppCtx.isElementAttached(containerEl))
|
||||
imagesToProcessInCurrentElement = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, false).elementsToProcess;
|
||||
|
||||
l.log(l.t(() => l.msg(`Found ${elementsToProcessInDom.length} of ${elementsToProcessInDom.length + remainingElements.length} images in DOM and ${imagesToProcessInCurrentElement.length} images in current HTML chunk to process.`)));
|
||||
await this.requestCache([...elementsToProcessInDom, ...imagesToProcessInCurrentElement], pass);
|
||||
});
|
||||
}
|
||||
|
||||
async requestCache(imageElements: HTMLImageElement[], pass: ProcessingPass) {
|
||||
const l = pass.ctx.logr;
|
||||
|
||||
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED));
|
||||
|
||||
l.log(l.t(() => l.msg("Plugin:requestCache", "\n\t", `Got ${imageElements.length} <img> elements to populate.`)));
|
||||
if (imageElements.length == 0) {
|
||||
l.log(l.abortMsg("nothing to do"));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cacheManager.checkIfMetadataFileChangedExternally();
|
||||
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, pass);
|
||||
|
||||
// First try to get src from local cache.
|
||||
for (const requestGroup of requestGroups) {
|
||||
const existingCacheResult = await this.cacheManager.existingCache(requestGroup.request, true);
|
||||
Log(`requestCache: ${existingCacheResult.item ? "Found" : "Did not find"} key ${existingCacheResult.cacheKey}. ${existingCacheResult.fileExists === undefined ? "Unknown if file exists." : `${existingCacheResult.fileExists ? "File exists." : "File does not exist."}`}`);
|
||||
l.log(l.t(() => l.msg(`Plugin:requestCache: ${existingCacheResult.item ? "Found" : "Did not find"} key ${existingCacheResult.cacheKey}. ${existingCacheResult.fileExists === undefined ? "Unknown if file exists." : `${existingCacheResult.fileExists ? "File exists." : "File does not exist."}`}`)));
|
||||
if (existingCacheResult.item)
|
||||
await handleRequestGroup(existingCacheResult.item, requestGroup);
|
||||
};
|
||||
|
|
@ -328,26 +403,31 @@ export default class ComeDownPlugin extends Plugin {
|
|||
let numberOfRemainingDownloads = remainingRequestGroups.length;
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
Log(`requestCache: All images were in cache. Done ${processingPass.passID}`);
|
||||
processingPass.end(this.cacheManager);
|
||||
l.log(l.msg("Plugin:requestCache: All images were in cache. Done."));
|
||||
pass.end(this.cacheManager); // Note: Must be called even though no cache additions were made to handle possible cache removals.
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are here, some images need to be downloaded.
|
||||
if (pass.ctx.isCacheAccessReadOnly) {
|
||||
l.log(l.abortMsg("Plugin:requestCache: Pass is read only. ", l.t(() => `Got ${requestGroups.length - numberOfRemainingDownloads} image from cache. ${numberOfRemainingDownloads} remaining.`)));
|
||||
|
||||
remainingRequestGroups.forEach((requestGroup) =>
|
||||
requestGroup.imageElements.forEach(el =>
|
||||
HtmlAssistant.setFailed(el, true)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show download notice
|
||||
if (this.settingsManager.settings.noticeOnDownload) {
|
||||
processingPass.runInUpdatePass(() => {
|
||||
const notice = `↓ ${numberOfRemainingDownloads}`;
|
||||
new Notice(notice, undefined, this.settingsManager.settings.omitNameInNotice);
|
||||
});
|
||||
const notice = `↓ ${numberOfRemainingDownloads}`;
|
||||
new Notice(notice, undefined, this.settingsManager.settings.omitNameInNotice);
|
||||
}
|
||||
|
||||
remainingRequestGroups.forEach((requestGroup) => {
|
||||
|
||||
for (const imageElement of requestGroup.imageElements) {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING_DOWNLOADING);
|
||||
HtmlAssistant.setLoadingIcon(imageElement);
|
||||
HtmlAssistant.setLoading(imageElement);
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, "Loading...");
|
||||
};
|
||||
|
||||
|
|
@ -360,31 +440,32 @@ export default class ComeDownPlugin extends Plugin {
|
|||
// Restore alt texts.
|
||||
imageElements.forEach((imageElement, index) => {
|
||||
const originalAlt = requestGroup.altAttributeValues[index];
|
||||
if (originalAlt.length > 0)
|
||||
if (originalAlt && originalAlt.length > 0)
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, originalAlt);
|
||||
else
|
||||
imageElement.removeAttribute(HTMLElementAttribute.ALT); // TODO: Chromium sets it to the original url if removed?
|
||||
});
|
||||
|
||||
if (result.item) {
|
||||
Log(`requestCache:\n\tSettings src on ${imageElements.length} images\n\t${result.item.metadata.f.n}\n\t${processingPass.isInPostProcessingPass ? `In HTML post processor` : `In edit listener`}`);
|
||||
const resultItem = result.item;
|
||||
l.log(Env.dev.thunkedStr(() => l.msg(`Plugin:requestCache:\n\tSettings src on ${imageElements.length} images\n\t${resultItem.metadata.f.n}\n\t${pass.ctx.ppCtx ? `In HTML post processor` : `In edit listener`}`)));
|
||||
if (result.fileExists === true)
|
||||
await handleRequestGroup(result.item, requestGroup);
|
||||
await handleRequestGroup(resultItem, requestGroup);
|
||||
else
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setInvalid(imageElement));
|
||||
|
||||
if (ENV.debugLog && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
console.error(`requestCache:\n\t${result.error.name}`, result.error);
|
||||
if (Env.isDev && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
l.log(l.msg(`Plugin:requestCache: ${result.error.name}`), result.error);
|
||||
|
||||
if (!(result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
console.error("requestCache:\n\tFailed to fetch cache", result.error);
|
||||
Env.log.e("Plugin:requestCache:\n\tFailed to fetch cache", result.error);
|
||||
}
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
processingPass.end(this.cacheManager);
|
||||
pass.end(this.cacheManager);
|
||||
if (result.error instanceof CacheFetchError && result.error.isInternetDisconnected)
|
||||
new Notice("No internet connection.");
|
||||
}
|
||||
|
|
@ -392,21 +473,21 @@ export default class ComeDownPlugin extends Plugin {
|
|||
});
|
||||
|
||||
/**
|
||||
* - Uses the cache item to load each image element in the request group.
|
||||
* - Sets the resulting {@link HTMLElementCacheState}.
|
||||
* - Sets {@link RequestGroup.cacheFileFound}
|
||||
* - Marks the src reference as retained.
|
||||
*
|
||||
* @param cacheItem
|
||||
* @param requestGroup
|
||||
*/
|
||||
* - Uses the cache item to load each image element in the request group.
|
||||
* - Sets the resulting {@link HTMLElementCacheState}.
|
||||
* - Sets {@link RequestGroup.cacheFileFound}
|
||||
* - Marks the src reference as retained.
|
||||
*
|
||||
* @param cacheItem
|
||||
* @param requestGroup
|
||||
*/
|
||||
async function handleRequestGroup(cacheItem: CacheItem, requestGroup: RequestGroup) {
|
||||
const imageElements = requestGroup.imageElements;
|
||||
const errorResult = await HtmlAssistant.loadImages(imageElements, cacheItem.resourcePath);
|
||||
|
||||
if (errorResult) {
|
||||
// File as not found on disk.
|
||||
console.error(errorResult.error);
|
||||
Env.log.e(errorResult.error);
|
||||
//imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
|
|
@ -416,29 +497,29 @@ export default class ComeDownPlugin extends Plugin {
|
|||
requestGroup.cacheFileFound = errorResult ? false : true;
|
||||
|
||||
if (requestGroup.cacheFileFound) {
|
||||
Log(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)}, ID ${processingPass.passID} 📦📦📦`);
|
||||
processingPass.retainCacheFromRequest(requestGroup.request);
|
||||
l.log(l.t(() => l.msg(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)} 📦📦📦`)));
|
||||
pass.retainCache(requestGroup.request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Create {@link CacheRequest} for each unique image source.
|
||||
* - Group images per unique request.
|
||||
* - Set the state to {@link HTMLElementCacheState.REQUESTING} on each element.
|
||||
* - Retain info that could be overwritten while making the request to be able to restore it afterwards.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, processingPass: ProcessingPass) {
|
||||
* - Create {@link CacheRequest} for each unique image source.
|
||||
* - Group images per unique request.
|
||||
* - Set the state to {@link HTMLElementCacheState.REQUESTING} on each element.
|
||||
* - Retain info that could be overwritten while making the request to be able to restore it afterwards.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param pass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, pass: ProcessingPass) {
|
||||
|
||||
const groupedByRequest: Record<string, RequestGroup> = {};
|
||||
|
||||
imageElements.forEach((imageElement) => {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING);
|
||||
|
||||
const src = HtmlAssistant.imageElementOriginalSrc(imageElement);
|
||||
const src = HtmlAssistant.originalSrc(imageElement, true);
|
||||
|
||||
if (src) {
|
||||
const key = CacheManager.createCacheKeyFromOriginalSrc(src);
|
||||
|
|
@ -450,10 +531,10 @@ export default class ComeDownPlugin extends Plugin {
|
|||
group.altAttributeValues.push(alt);
|
||||
}
|
||||
else {
|
||||
const request: CacheRequest = { source: src, requesterPath: processingPass.associatedFile.path };
|
||||
const request = pass.ctx.isCacheAccessReadWrite() ? CacheManager.createRequest(src, pass.ctx.associatedFile.path) : CacheManager.createReadOnlyRequest(src);
|
||||
const validationError = cacheManager.validateRequest(request);
|
||||
if (validationError) {
|
||||
console.error(`Cache request failed validation.`, validationError)
|
||||
Env.log.e(`Cache request failed validation.`, validationError)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
else {
|
||||
|
|
@ -467,7 +548,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
else {
|
||||
console.error(`Image element does not have a source. Omitting.`)
|
||||
Env.log.e(`Image element does not have a source. Omitting.`)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
});
|
||||
|
|
@ -475,20 +556,36 @@ export default class ComeDownPlugin extends Plugin {
|
|||
return Object.values(groupedByRequest);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
/**
|
||||
* Group of images that share the same cash request.
|
||||
*/
|
||||
* Group of images that share the same cash request.
|
||||
*/
|
||||
interface RequestGroup {
|
||||
request: CacheRequest,
|
||||
imageElements: HTMLImageElement[],
|
||||
altAttributeValues: string[],
|
||||
/**
|
||||
* Set to true to indicate that the cache was available and there's no need to download it.
|
||||
* @description This is just a helper because one could iterate the image elements and check the {@link HTMLElementCacheState} to find out.
|
||||
*/
|
||||
* Set to true to indicate that the cache was available and there's no need to download it.
|
||||
* @description This is just a helper because one could iterate the image elements and check the {@link HTMLElementCacheState} to find out.
|
||||
*/
|
||||
cacheFileFound: boolean,
|
||||
}
|
||||
|
||||
const PluginFile = {
|
||||
Static: {
|
||||
MANIFEST: "manifest.json",
|
||||
MAIN: "main.js",
|
||||
STYLES: "styles.css",
|
||||
} as const,
|
||||
Dynamic: {
|
||||
DATA: {
|
||||
NAME: "data",
|
||||
EXT: ".json",
|
||||
} as const,
|
||||
CACHE: {
|
||||
NAME: "cache",
|
||||
EXT: ".json",
|
||||
} as const,
|
||||
} as const,
|
||||
} as const;
|
||||
|
|
|
|||
67
src/processing/EditorViewPlugin.ts
Normal file
67
src/processing/EditorViewPlugin.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "Env";
|
||||
|
||||
/**
|
||||
* Custom metadata added to the {@link EditorView}.
|
||||
* An {@link EditorView} manages plugins and may destroy and create new ones.
|
||||
* Thus this metadata outlives the {@link EditorViewPlugin} from which it is fetched, see {@link EditorViewPlugin.getViewMetadata}.
|
||||
*/
|
||||
export interface EditorViewMetadata {
|
||||
requesterPath: string | null;
|
||||
//newFileDetectedAtSeqNum: number | null;
|
||||
}
|
||||
|
||||
const DEFAULT: EditorViewMetadata = {
|
||||
requesterPath: null,
|
||||
//newFileDetectedAtSeqNum: null,
|
||||
}
|
||||
|
||||
export interface EditorViewPluginInfo {
|
||||
readonly seqNum: number;
|
||||
}
|
||||
|
||||
export class EditorViewPlugin {
|
||||
|
||||
private seqNum = 0;
|
||||
|
||||
constructor(public view: EditorView) {
|
||||
Env.log.d("EditorViewPlugin:constructor");
|
||||
}
|
||||
|
||||
update(_update: ViewUpdate) {
|
||||
//Env.log.d("EditorViewPlugin:update", this.getViewMetadata(update.view));
|
||||
}
|
||||
|
||||
postUpdate(update: ViewUpdate, handler: (update: ViewUpdate, plugin: EditorViewPlugin, info: EditorViewPluginInfo) => void) {
|
||||
Env.log.d("EditorViewPlugin:postUpdate", this.getViewMetadata(update.view));
|
||||
handler(update, this, {
|
||||
seqNum: this.seqNum++,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Env.log.d("EditorViewPlugin:destroy");
|
||||
}
|
||||
|
||||
public getViewMetadata(view: EditorView) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
return v[METADATA_KEY] ?? DEFAULT;
|
||||
}
|
||||
|
||||
/** Save changes immediately if you want them to be available to the next pass (which might begin before the current has finished). */
|
||||
public setViewMetadata(view: EditorView, data: EditorViewMetadata) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
v[METADATA_KEY] = data;
|
||||
}
|
||||
|
||||
public clearViewMetadata(view: EditorView) {
|
||||
const v = view as EditorViewWithMetadata;
|
||||
delete v[METADATA_KEY];
|
||||
}
|
||||
}
|
||||
|
||||
const METADATA_KEY = Symbol("EditorViewMetadata");
|
||||
|
||||
interface EditorViewWithMetadata extends EditorView {
|
||||
[METADATA_KEY]?: EditorViewMetadata;
|
||||
}
|
||||
361
src/processing/HtmlAssistant.ts
Normal file
361
src/processing/HtmlAssistant.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { Env } from "Env";
|
||||
import { ObsAssistant } from "utils/ObsAssistant";
|
||||
import { Err } from "utils/ts";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
export const enum HTMLElementCacheState {
|
||||
/** Untouched by the plugin. */
|
||||
ORIGINAL = 0,
|
||||
|
||||
/**
|
||||
* The original src url has been removed. Element is ready to request.
|
||||
*
|
||||
* A `data` attribute has been set on the element with the original src, get it with {@link HtmlAssistant.originalSrc}.
|
||||
*/
|
||||
ORIGINAL_SRC_REMOVED,
|
||||
|
||||
/** Requesting cache. If cache found, will be changed to {@link CACHE_SUCCEEDED}; if not, to {@link REQUESTING_DOWNLOADING}. */
|
||||
REQUESTING,
|
||||
|
||||
/** Cache item was not found. Downloading. */
|
||||
REQUESTING_DOWNLOADING,
|
||||
|
||||
/** The element's src is now pointing to the locally cached item, whether it was fetched from cache or downloaded. */
|
||||
CACHE_SUCCEEDED,
|
||||
|
||||
/**
|
||||
* Cache was requested but failed. As opposed to {@link INVALID}, these can be retried.
|
||||
* Basically equal to {@link ORIGINAL_SRC_REMOVED} except that they have requested at least once.
|
||||
*
|
||||
* For example, connection failed.
|
||||
*/
|
||||
CACHE_FAILED,
|
||||
|
||||
/**
|
||||
* For example,
|
||||
* - the url doesn't exist (404)
|
||||
* - the url exists but is irrelevant, e.g., https://example.com/
|
||||
*
|
||||
* Elements in this state are ignored. The may or may not have an icon set ({@link HtmlAssistant.setIcon}), e.g., if the URL was requested but resulted in 404.
|
||||
*/
|
||||
INVALID,
|
||||
}
|
||||
|
||||
export const enum HTMLElementAttribute {
|
||||
SRC = "src",
|
||||
ALT = "alt",
|
||||
};
|
||||
|
||||
export class HtmlAssistant {
|
||||
|
||||
public static isElementCacheStateEqual(element: HTMLElement, ...states: HTMLElementCacheState[]): boolean {
|
||||
return this.isCacheStateEqual(this.cacheState(element), ...states);
|
||||
}
|
||||
|
||||
/** @returns `true` if one of the {@link states} is equal to {@link state}. */
|
||||
public static isCacheStateEqual(state: HTMLElementCacheState, ...states: HTMLElementCacheState[]): boolean {
|
||||
for (const stateToCheck of states)
|
||||
if (stateToCheck == state)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param element
|
||||
* @returns If {@link element} is lacking a state, {@link HTMLElementCacheState.ORIGINAL} is returned.
|
||||
*/
|
||||
public static cacheState(element: HTMLElement): HTMLElementCacheState {
|
||||
const state = element.dataset.comeDownState;
|
||||
|
||||
if (state === undefined) {
|
||||
Env.log.d("HtmlAssistant:cacheState: No custom state `data-` attribute was found on the element. Returning", HTMLElementCacheState.ORIGINAL);
|
||||
return HTMLElementCacheState.ORIGINAL;
|
||||
}
|
||||
|
||||
const numericState = Number(state);
|
||||
|
||||
if (
|
||||
numericState !== HTMLElementCacheState.ORIGINAL &&
|
||||
numericState !== HTMLElementCacheState.ORIGINAL_SRC_REMOVED &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING &&
|
||||
numericState !== HTMLElementCacheState.REQUESTING_DOWNLOADING &&
|
||||
numericState !== HTMLElementCacheState.CACHE_SUCCEEDED &&
|
||||
numericState !== HTMLElementCacheState.CACHE_FAILED &&
|
||||
numericState !== HTMLElementCacheState.INVALID
|
||||
) {
|
||||
throw new Error(`Invalid cache state: ${state}`);
|
||||
}
|
||||
|
||||
return numericState as HTMLElementCacheState;
|
||||
}
|
||||
|
||||
public static setCacheState(element: HTMLElement, state: HTMLElementCacheState) {
|
||||
Env.log.d("HtmlAssistant:setCacheState:", state);
|
||||
element.dataset.comeDownState = state.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods returns before the images have loaded but guarantees that they will load
|
||||
* unless the returned result's `error` is set.
|
||||
*
|
||||
* @param imageElements
|
||||
* @param src
|
||||
* @returns Assume file not found when an {@link Error} is returned, see {@link createBlobObjectUrl}.
|
||||
*/
|
||||
public static async loadImages(imageElements: HTMLImageElement[], src: string) {
|
||||
const result = await this.createBlobObjectUrl(src);
|
||||
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
error: result,
|
||||
fileNotFound: result instanceof HtmlAssistantFileNotFoundError,
|
||||
};
|
||||
}
|
||||
else {
|
||||
let remainingLoads = imageElements.length;
|
||||
|
||||
const onLoad = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.removeEventListener("load", onLoad);
|
||||
|
||||
remainingLoads--;
|
||||
if (remainingLoads === 0) {
|
||||
URL.revokeObjectURL(result);
|
||||
}
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, result);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all the data sets from the element.
|
||||
* @param element
|
||||
*/
|
||||
public static resetElement(element: HTMLElement) {
|
||||
delete element.dataset.comeDownOriginalSource;
|
||||
delete element.dataset.comeDownState;
|
||||
}
|
||||
|
||||
public static setLoading(imageElement: HTMLImageElement) {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING_DOWNLOADING);
|
||||
HtmlAssistant.setIcon(imageElement, HtmlAssistant.loadingIcon);
|
||||
}
|
||||
|
||||
public static setFailed(element: HTMLImageElement, readOnlyAccess = false) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.CACHE_FAILED);
|
||||
HtmlAssistant.setIcon(element, readOnlyAccess ? HtmlAssistant.readOnlyIcon : HtmlAssistant.failedIcon);
|
||||
}
|
||||
|
||||
public static setInvalid(element: HTMLImageElement) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.INVALID);
|
||||
HtmlAssistant.setIcon(element, HtmlAssistant.failedIcon);
|
||||
}
|
||||
|
||||
private static setCanceled(element: HTMLImageElement) {
|
||||
HtmlAssistant.setCacheState(element, HTMLElementCacheState.ORIGINAL_SRC_REMOVED);
|
||||
HtmlAssistant.setIcon(element, HtmlAssistant.placeholderIcon);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Removes the `src` attribute to prevent ordinary loading.
|
||||
* - Sets the state to {@link HTMLElementCacheState.ORIGINAL_SRC_REMOVED}.
|
||||
* - Will not do anything if:
|
||||
* - `src` attrib is missing or empty string; if
|
||||
* - {@link imageElements} is empty.
|
||||
*
|
||||
* @param imageElements
|
||||
*/
|
||||
public static cancelImageLoadIfNeeded(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("HtmlAssistant:cancelImageLoading: Number of images to check:", imageElements.length);
|
||||
if (imageElements.length === 0)
|
||||
return;
|
||||
|
||||
const cancelImageLoad = (imageElement: HTMLImageElement): boolean => {
|
||||
|
||||
// Only continue if there's something to cancel.
|
||||
const src = this.getSrc(imageElement);
|
||||
if (src === null)
|
||||
return false;
|
||||
|
||||
if (HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL_SRC_REMOVED)
|
||||
return false;
|
||||
|
||||
if (Env.isDev && src && Url.isBlob(src))
|
||||
console.warn(`cancelImageLoading: Setting dataset to blob.`);
|
||||
|
||||
imageElement.dataset.comeDownOriginalSource = src;
|
||||
HtmlAssistant.setCanceled(imageElement);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
imageElements.forEach((imageElement) => {
|
||||
if (cancelImageLoad(imageElement))
|
||||
counter++;
|
||||
});
|
||||
|
||||
Env.log.d(`\tCancelled ${counter} of ${imageElements.length}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* First checks if state is set to {@link HTMLElementCacheState.ORIGINAL_SRC_REMOVED}, if so, returns `true`.
|
||||
* If not, checks if the images `src` is a valid, external URL.
|
||||
*
|
||||
* Because images that need to be processed may have been set to icons (non-external urls), it is preferred to use this method rather than {@link Url.isValidExternalUrl} directly.
|
||||
*/
|
||||
public static isImageToProcess(imageElement: HTMLImageElement) {
|
||||
// Check if it's already been canceled before checking the url.
|
||||
if (HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL_SRC_REMOVED)
|
||||
return true;
|
||||
|
||||
const src = HtmlAssistant.getSrc(imageElement);
|
||||
if (Url.isValidExternalUrl(src))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @returns The value of calling {@link Url.normalizeUrl} on the `src` of the {@link element}. */
|
||||
public static getSrc(element: HTMLImageElement): string | null {
|
||||
return Url.normalizeUrl(element.getAttribute(HTMLElementAttribute.SRC));
|
||||
}
|
||||
|
||||
/** @returns A trimmed, non-empty string; or `null` if no original source exists on the element. */
|
||||
public static originalSrc(element: HTMLImageElement, checkSrc: boolean = false): string | null {
|
||||
const src = element.dataset.comeDownOriginalSource ?? null;
|
||||
if (src === null && checkSrc && HtmlAssistant.cacheState(element) === HTMLElementCacheState.ORIGINAL)
|
||||
return HtmlAssistant.getSrc(element)
|
||||
else
|
||||
return src;
|
||||
}
|
||||
|
||||
/**
|
||||
* - 'img' selects all <img> elements.
|
||||
* - [src] selects only <img> elements that have a src attribute.
|
||||
* - :not([aria-hidden="true"]) filters out <img> elements that have the aria-hidden="true" attribute.
|
||||
*
|
||||
* @param element Search children of this element.
|
||||
* @param requireSrcAttribute If `true` will not return image elements that don't have the `src` attribute.
|
||||
* @param filter
|
||||
* @returns
|
||||
*/
|
||||
public static findAllImageElements(element: HTMLElement, requireSrcAttribute: boolean = true, filter?: (imageElement: HTMLImageElement) => boolean): HTMLImageElement[] {
|
||||
let imageElements;
|
||||
if (requireSrcAttribute)
|
||||
imageElements = element.findAll(HtmlAssistant.FIND_ALL_IMG_SELECTOR_REQ_SRC) as HTMLImageElement[];
|
||||
else
|
||||
imageElements = element.findAll(HtmlAssistant.FIND_ALL_IMG_SELECTOR) as HTMLImageElement[];
|
||||
|
||||
return filter ? imageElements.filter((imageElement) => filter(imageElement)) : imageElements;
|
||||
}
|
||||
private static readonly FIND_ALL_IMG_SELECTOR_REQ_SRC = 'img[src]:not([aria-hidden="true"])';
|
||||
private static readonly FIND_ALL_IMG_SELECTOR = 'img:not([aria-hidden="true"])';
|
||||
|
||||
/**
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications#using_object_urls|Using object URLs}
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* If {@link src} points to a local file that doesn't exist, the Dev Tools will show "net::ERR_FILE_NOT_FOUND".
|
||||
* The net::ERR_FILE_NOT_FOUND message you see in the console comes from the browser’s internal networking layer, only shown in DevTools, not available to JavaScript code.
|
||||
* However, in Electron and Chrome-based browsers, fetch() throwing a TypeError generally means the file doesn’t exist.
|
||||
*
|
||||
* Capacitor also, which runs on the native mobile webview, also throws a `TypeError` on iOS (not tested on Android).
|
||||
*
|
||||
* To be safe, assume all `Error`s is file not found.
|
||||
*
|
||||
* @param src Url to a local resource. Would start with `app://`, `capacitor://`, or perhaps `file://`
|
||||
* @returns On failure, a {@link HtmlAssistantFileNotFoundError} if it thinks the a give local file doesn't exist; otherwise a normal Error.
|
||||
*/
|
||||
private static async createBlobObjectUrl(src: string): Promise<string | Error> {
|
||||
let response;
|
||||
|
||||
try {
|
||||
// Response from `requestUrl` does not allow for creating blobs. These will always be local urls, so no need to handle CORS.
|
||||
response = await fetch(src);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
return new HtmlAssistantFileNotFoundError(src);
|
||||
else
|
||||
return Err.toError(error);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
try {
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (url && URL.canParse(url) && Url.isBlob("blob:"))
|
||||
return url;
|
||||
else
|
||||
return new Error(`Invalid blob URL: ${url}`);
|
||||
} catch (error) {
|
||||
return Err.toError(error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return new Error(`Unsuccessful response: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static setIcon(imageElement: HTMLImageElement, blob: Blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const onLoad = (_event: Event) => {
|
||||
imageElement.removeEventListener("load", onLoad);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
imageElement.addEventListener("load", onLoad);
|
||||
imageElement.setAttribute(HTMLElementAttribute.SRC, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {@link https://lucide.dev/icons/loader}
|
||||
*/
|
||||
private static get loadingIcon(): Blob {
|
||||
if (this.loadingIconBacking === undefined) {
|
||||
const icon = ObsAssistant.getIcon("image-down", { fallbackIconID: "bug" }); // "loader"
|
||||
this.loadingIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.loadingIconBacking;
|
||||
}
|
||||
private static loadingIconBacking?: Blob;
|
||||
|
||||
private static get failedIcon(): Blob {
|
||||
if (this.failedIconBacking === undefined) {
|
||||
const icon = ObsAssistant.getIcon("image", { color: "#ff0000", fallbackIconID: "bug" }); // image-off
|
||||
this.failedIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.failedIconBacking;
|
||||
}
|
||||
private static failedIconBacking?: Blob;
|
||||
|
||||
private static get readOnlyIcon(): Blob {
|
||||
if (this.readOnlyIconBacking === undefined) {
|
||||
const icon = ObsAssistant.getIcon("meh"); // frown ellipsis square-check activity
|
||||
this.readOnlyIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
|
||||
}
|
||||
return this.readOnlyIconBacking;
|
||||
}
|
||||
private static readOnlyIconBacking?: Blob;
|
||||
|
||||
private static get placeholderIcon(): Blob {
|
||||
if (this.placeholderIconBacking === undefined)
|
||||
this.placeholderIconBacking = new Blob(['<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg"></svg>'], { type: "image/svg+xml" });
|
||||
return this.placeholderIconBacking;
|
||||
}
|
||||
private static placeholderIconBacking?: Blob;
|
||||
}
|
||||
|
||||
class HtmlAssistantFileNotFoundError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`File not found: ${url}`);
|
||||
this.name = "HtmlAssistantFileNotFoundError";
|
||||
}
|
||||
}
|
||||
26
src/processing/Logger.ts
Normal file
26
src/processing/Logger.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Env } from "Env";
|
||||
import { Logger as BaseLogger } from "utils/Logger";
|
||||
|
||||
export class Logger extends BaseLogger {
|
||||
|
||||
public beginMsg(...args: unknown[]) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
|
||||
return `${this.symbol} Begin pass. ${this.joinArgs(args)} ➡️🚪 ${this.idString}`;
|
||||
}
|
||||
|
||||
public endMsg(...args: unknown[]) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
|
||||
return `${this.symbol} End pass. Finished. ${this.joinArgs(args)} ✅🚪➡️ ${this.idString}`;
|
||||
}
|
||||
|
||||
public abortMsg(...args: unknown[]) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
|
||||
return `${this.symbol} End pass. Aborted. ${this.joinArgs(args)} ❌🚪➡️ ${this.idString}`;
|
||||
}
|
||||
}
|
||||
385
src/processing/ProcessingContext.ts
Normal file
385
src/processing/ProcessingContext.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "Env";
|
||||
import { App, ItemView, MarkdownPostProcessorContext, MarkdownView, TFile, View } from "obsidian";
|
||||
import { Logger } from "processing/Logger";
|
||||
import { ObsAssistant, ObsViewMode } from "utils/ObsAssistant";
|
||||
import { isDescendantOrEqual } from "utils/dom";
|
||||
import { Arr } from "utils/ts";
|
||||
|
||||
export class ProcessingContext {
|
||||
|
||||
public readonly vuCtx: ViewUpdateContext | null;
|
||||
public readonly ppCtx: PostProcessorContext | null;
|
||||
public readonly logr: Logger;
|
||||
|
||||
/**
|
||||
* `<div class="workspace-leaf-content" data-type="{view type}">`
|
||||
*
|
||||
* - Will be `null` if processing outside of a {@link View}.
|
||||
* - If {@link view} is not `null`, then this value will use {@link View.containerEl} (instead of searching for it in the DOM).
|
||||
* - Might not be `null` even if {@link view} is `null`.
|
||||
*/
|
||||
public readonly viewContainerEl: HTMLElement | null;
|
||||
|
||||
/**
|
||||
* `null` if an instance of a relevant {@link View} could not be found.
|
||||
*/
|
||||
public readonly view: View | null;
|
||||
public readonly markdownView: MarkdownView | null;
|
||||
public readonly viewingMode: ObsViewMode;
|
||||
|
||||
public readonly associatedFile: TFile | null;
|
||||
|
||||
public readonly isCacheAccessReadOnly: boolean;
|
||||
|
||||
constructor(log: Logger, view: View | null, mode: ObsViewMode, viewContainerEl: HTMLElement | null, associatedFile: TFile | null, underlyingCtx: ViewUpdateContext | PostProcessorContext) {
|
||||
this.logr = log;
|
||||
|
||||
this.view = view;
|
||||
this.markdownView = view instanceof MarkdownView ? view : null;
|
||||
|
||||
this.viewingMode = mode;
|
||||
this.associatedFile = associatedFile;
|
||||
|
||||
this.vuCtx = underlyingCtx instanceof ViewUpdateContext ? underlyingCtx : null;
|
||||
this.ppCtx = underlyingCtx instanceof PostProcessorContext ? underlyingCtx : null;
|
||||
Env.dev.assert(this.vuCtx !== null || this.ppCtx !== null);
|
||||
|
||||
this.viewContainerEl = viewContainerEl;
|
||||
|
||||
this.isCacheAccessReadOnly = !this.isCacheAccessReadWrite();
|
||||
}
|
||||
|
||||
public static fromViewUpdate(app: App, logger: Logger, viewUpdate: ViewUpdate): ProcessingContext {
|
||||
|
||||
// <div class="workspace-leaf-content" data-type="markdown" data-mode="source"> <-- viewContainerEl / View.containerEl
|
||||
// <div class="view-content"> <-- ItemView.contentEl
|
||||
// <div class"markdown-source-view">
|
||||
// <div class="cm-editor cm-focused ͼ1 ͼ2 "> <-- ViewUpdateContext.editorEl / EditorView.dom
|
||||
// <div class="cm-scroller">
|
||||
// <div class="cm-sizer">
|
||||
// <div class="cm-contentContainer">
|
||||
// <div class="cm-content"> <-- ViewUpdateContext.contentEl / EditorView.contentDOM
|
||||
// <div dir="ltr" class="cm-line"> <-- document content starts here
|
||||
// <img src="blob:…" contenteditable="false" data-come-down-original-source="https://…" data-come-down-state="4">
|
||||
|
||||
|
||||
const contentEl = viewUpdate.view.contentDOM;
|
||||
let viewContainerEl: HTMLElement | null = null;
|
||||
|
||||
const view = ProcessingContext.tryGetViewInstance(app, contentEl);
|
||||
if (view)
|
||||
viewContainerEl = view.containerEl;
|
||||
|
||||
// No view, get the container by traversing up.
|
||||
if (viewContainerEl === null)
|
||||
viewContainerEl = Arr.firstOrNull(ObsAssistant.viewContainerEl({ element: contentEl, dir: "up" }));
|
||||
|
||||
const viewingMode = ProcessingContext.tryGetViewingMode(view ?? undefined, viewContainerEl ?? undefined);
|
||||
|
||||
let associatedFile: TFile | null = null;
|
||||
if (view !== null)
|
||||
associatedFile = ObsAssistant.getFileFromView(view);
|
||||
|
||||
return new ProcessingContext(logger, view, viewingMode, viewContainerEl, associatedFile, new ViewUpdateContext(viewUpdate, view));
|
||||
}
|
||||
|
||||
public static fromPostProcessor(app: App, logger: Logger, element: HTMLElement, postProcessorContext: MarkdownPostProcessorContext): ProcessingContext & { readonly ppCtx: PostProcessorContext } {
|
||||
|
||||
// <div class="workspace-leaf-content" data-type="markdown" data-mode="preview"> <-- `containerEl`
|
||||
// <div class="view-content"> <--
|
||||
// <div class"markdown-reading-view">
|
||||
// <div class="markdown-preview-view markdown-renderered"> <--
|
||||
// <div class="markdown-preview-sizer markdown-preview-section">
|
||||
// <div class="markdown-preview-pusher">
|
||||
// <div class="mod-header mod-ui">
|
||||
// <div class="el-pre mod-frontmatter mod-ui">
|
||||
// <div class="el-h1"> <-- document content starts here
|
||||
// <div class="el-p">
|
||||
// <div>
|
||||
// <img src="blob:…" contenteditable="false" data-come-down-original-source="https://…" data-come-down-state="4">
|
||||
|
||||
let viewContainerEl: HTMLElement | null = null;
|
||||
|
||||
const view = ProcessingContext.tryGetViewInstance(app, element);
|
||||
if (view)
|
||||
viewContainerEl = view.containerEl;
|
||||
|
||||
// No view, get the container.
|
||||
if (viewContainerEl === null)
|
||||
viewContainerEl = Arr.firstOrNull(ObsAssistant.viewContainerEl({ element: element, postProcessorContext: postProcessorContext, dir: "up" }));
|
||||
|
||||
let viewingMode = ProcessingContext.tryGetViewingMode(view ?? undefined, viewContainerEl ?? undefined);
|
||||
// If there's a MarkdownPostProcessorContext the markdown has already been processed so must be read only…
|
||||
if (viewingMode === "none")
|
||||
viewingMode = "reader";
|
||||
|
||||
// When processing markdown, there should be a source file from which the markdown came from.
|
||||
// If `MarkdownRenderer.render` lead to this processing pass `context.sourcePath` is what was passed to that method. Perhaps invalid paths are not validated.
|
||||
let associatedFile: TFile | null = app.vault.getFileByPath(postProcessorContext.sourcePath);
|
||||
|
||||
// Fallback
|
||||
// For example, if a call to `MarkdownRenderer.render`, which takes a `sourcePath`, caused this post processing, then `MarkdownPostProcessorContext.sourcePath` is set to whatever the caller passed to `render`. Perhaps there's no check for the existance of an actual file for that path.
|
||||
if (associatedFile === null && view !== null)
|
||||
associatedFile = ObsAssistant.getFileFromView(view);
|
||||
|
||||
const ctx = new ProcessingContext(logger, view, viewingMode, viewContainerEl, associatedFile, new PostProcessorContext(element, postProcessorContext));
|
||||
return ctx as ProcessingContext & { readonly ppCtx: PostProcessorContext };
|
||||
}
|
||||
|
||||
public static logInit(ctx: ProcessingContext) {
|
||||
if (!Env.isDev)
|
||||
return ctx;
|
||||
|
||||
const l = ctx.logr;
|
||||
|
||||
if (ctx.view !== null) {
|
||||
l.log(l.msg("\t", "Found `View` instance of class:", ObsAssistant.viewClassTypeAsSting(ctx.view) + " / " + ctx.view.getViewType()));
|
||||
}
|
||||
else {
|
||||
if (ctx.viewContainerEl)
|
||||
l.log(l.msg("\t", "Did not find `View` instance, but found view container element:", ctx.viewContainerEl.classList.value));
|
||||
else {
|
||||
l.log(l.msg("\t", "Did not find `View` instance nor a view container element"));
|
||||
|
||||
const ctxEl = ctx.ppCtx?.mdCtx ? ObsAssistant.containerElFromPostProcessorContext(ctx.ppCtx.mdCtx) : null;
|
||||
if (ctxEl)
|
||||
l.log(l.msg("\t", "Found a container element in the post processing context", ctxEl.classList.value));
|
||||
}
|
||||
}
|
||||
|
||||
l.log(l.msg("\t", "Primary element", (ctx.ppCtx ? ctx.ppCtx.element : ctx.vuCtx!.contentEl).classList.value));
|
||||
l.log(l.msg("\t", "View mode:", ctx.viewingMode, ", File:", ctx.associatedFile?.basename ?? "No associated file"));
|
||||
|
||||
if (ctx.vuCtx && ctx.viewingMode === "preview") {
|
||||
const u = ctx.vuCtx.viewUpdates();
|
||||
const changed = Object.keys(u).filter(key => u[key]);
|
||||
const notChanged = Object.keys(u).filter(key => !u[key]);
|
||||
|
||||
ctx.logr.log("\t", `${changed.length} view updates:`);
|
||||
if (changed.length > 0)
|
||||
ctx.logr.log("\t", `🟢 ${changed.join(", ")}`);
|
||||
if (changed.length > 0 && notChanged.length > 0)
|
||||
ctx.logr.log("\t", `🔴 ${notChanged.join(", ")}`);
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public assertViewUpdateContext(): asserts this is this & { readonly vuCtx: ViewUpdateContext } {
|
||||
if (!this.vuCtx)
|
||||
throw new Error("Not in edit update context.");
|
||||
}
|
||||
|
||||
public assertPostProcessor(): asserts this is this & { readonly ppCtx: PostProcessorContext } {
|
||||
if (!this.ppCtx)
|
||||
throw new Error("Not in markdown post processing context.");
|
||||
}
|
||||
|
||||
/** @returns `true` if if the requestor/retainer path is available. */
|
||||
public isCacheAccessReadWrite(): this is this & { readonly associatedFile: TFile } {
|
||||
// Determine whether it is read write here.
|
||||
return this.associatedFile !== null;
|
||||
}
|
||||
|
||||
public get isInPostProcessingPass() {
|
||||
return this.ppCtx !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @remarks The workspace's active view, if any, is not necessarily the where to current processing occurs.
|
||||
*
|
||||
* @param element The view container (or a child element) which content is currently being processed.
|
||||
* @returns The active {@link View} instance if its `containerEl` is equal to {@link element}
|
||||
*/
|
||||
private static tryGetViewInstance(app: App, element: HTMLElement) {
|
||||
const activeView = ObsAssistant.getActiveView(app);
|
||||
return activeView !== null && isDescendantOrEqual(activeView.containerEl, element) ? activeView : null;
|
||||
}
|
||||
|
||||
/** Will try by using {@link view} first; if fails, will try {@link viewContainerEl}. */
|
||||
private static tryGetViewingMode(view?: View, viewContainerEl?: HTMLElement) {
|
||||
let viewMode: ObsViewMode = "none";
|
||||
|
||||
if (view)
|
||||
viewMode = ObsAssistant.viewMode(view);
|
||||
|
||||
if (viewMode === "none" && viewContainerEl)
|
||||
viewMode = ObsAssistant.viewModeFromContainerEl(viewContainerEl);
|
||||
|
||||
return viewMode;
|
||||
}
|
||||
|
||||
public getPreferredContainerElFromViewUpdate(): HTMLElement {
|
||||
const el = this.getPreferredContainerEl();
|
||||
if (el === null)
|
||||
throw new Error("Expected non-null container element");
|
||||
return el;
|
||||
}
|
||||
|
||||
public getPreferredContainerEl() {
|
||||
Env.log.d(this.logr.t(() => this.logr.msg(`ProcessingContext:getPreferredContainerEl: is post processor: ${this.isInPostProcessingPass}; viewingMode ${this.viewingMode}; has viewContanerEl: ${this.viewContainerEl ? true : false}`)));
|
||||
|
||||
if (this.vuCtx)
|
||||
return this.vuCtx.contentEl;
|
||||
|
||||
this.assertPostProcessor();
|
||||
const pp = this.ppCtx;
|
||||
const viewOrUndefined = this.view ?? undefined;
|
||||
let el: HTMLElement | null = null;
|
||||
|
||||
// Check if reading view container is available first, this is the primary reading view container used in, e.g., tabs.
|
||||
// A popover, for example, do not have a reading view.
|
||||
|
||||
if (pp.isElementAttached()) {
|
||||
el = Arr.firstOrNull(ObsAssistant.readingViewEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.viewContentEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.popoverEl({ element: pp.element, dir: "up" }));
|
||||
el = el ?? Arr.firstOrNull(ObsAssistant.previewViewEl({ element: pp.element, dir: "up", allDescendants: false }));
|
||||
}
|
||||
else {
|
||||
if (this.viewContainerEl)
|
||||
el = Arr.firstOrNull(ObsAssistant.readingViewEl({ element: this.viewContainerEl, dir: "down" }));
|
||||
|
||||
if (el === null) {
|
||||
if (this.view instanceof ItemView)
|
||||
el = this.view.contentEl;
|
||||
else if (this.viewContainerEl)
|
||||
el = Arr.firstOrNull(ObsAssistant.viewContentEl({ element: this.viewContainerEl, dir: "down" }));
|
||||
}
|
||||
|
||||
// Ex: Hover over a note in file explorer to display a popover preview where images are outside of the viewport so their HTML chunks are not connected to the document.
|
||||
if (el === null)
|
||||
el = Arr.firstOrNull(ObsAssistant.popoverEl({ view: viewOrUndefined, postProcessorContext: pp.mdCtx, dir: "updown" }));
|
||||
}
|
||||
|
||||
// Do the utmost to find a preview view
|
||||
if (el === null) {
|
||||
el = Arr.firstOrNull(ObsAssistant.previewViewEl({
|
||||
dir: "updown",
|
||||
element: pp.element,
|
||||
view: viewOrUndefined,
|
||||
postProcessorContext: pp.mdCtx,
|
||||
allDescendants: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (Env.isDev && el !== null && this.viewingMode === "reader") {
|
||||
const sourceView = Arr.firstOrNull(ObsAssistant.sourceViewEl({ element: el, dir: "down" }));
|
||||
if (sourceView)
|
||||
Env.log.w("ProcessingContext:preferredEl:", "Reader has access to source view.");
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
public domEquals(other: ProcessingContext): boolean {
|
||||
Env.assert(this.vuCtx !== null && other.vuCtx !== null || this.ppCtx !== null && other.ppCtx !== null);
|
||||
let eq = true;
|
||||
|
||||
if (this.vuCtx !== null && other.vuCtx !== null)
|
||||
eq = this.vuCtx.equalsDom(other.vuCtx);
|
||||
else if (this.ppCtx !== null && other.ppCtx !== null)
|
||||
eq = this.ppCtx.equalsDom(other.ppCtx);
|
||||
else
|
||||
Env.assert();
|
||||
|
||||
if (eq)
|
||||
eq = this.viewContainerEl === other.viewContainerEl;
|
||||
|
||||
return eq;
|
||||
}
|
||||
}
|
||||
|
||||
class ViewUpdateContext {
|
||||
|
||||
public readonly viewUpdate: ViewUpdate;
|
||||
public readonly view: View | null;
|
||||
|
||||
/** <div class="cm-editor"> */
|
||||
public readonly editorEl: HTMLElement;
|
||||
|
||||
/** `<div class="cm-content">` */
|
||||
public readonly contentEl: HTMLElement;
|
||||
|
||||
//public readonly state: EditUpdateState;
|
||||
|
||||
constructor(viewUpdate: ViewUpdate, view: View | null) {
|
||||
this.viewUpdate = viewUpdate;
|
||||
this.view = view;
|
||||
|
||||
this.editorEl = viewUpdate.view.dom;
|
||||
this.contentEl = viewUpdate.view.contentDOM;
|
||||
|
||||
//this.state = EditUpdateState.get(viewUpdate);
|
||||
}
|
||||
|
||||
public equalsDom(other: ViewUpdateContext): boolean {
|
||||
return this.editorEl === other.editorEl && this.contentEl === other.contentEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Never seems to become more specific / go deeper than the {@link contentEl}.
|
||||
* Will be equal to {@link contentEl} when cursor is below the frontmatter.
|
||||
*/
|
||||
public get activeEl(): Element | null {
|
||||
return this.viewUpdate.view.root.activeElement;
|
||||
}
|
||||
|
||||
public get isContentElActive() {
|
||||
return isDescendantOrEqual(this.contentEl, this.activeEl);
|
||||
}
|
||||
|
||||
public get hasUpdates() {
|
||||
const vu = this.viewUpdate;
|
||||
return vu.docChanged || vu.viewportChanged || vu.selectionSet || vu.focusChanged || vu.geometryChanged || vu.heightChanged || vu.viewportMoved;
|
||||
}
|
||||
|
||||
public viewUpdates(): Record<string, boolean> {
|
||||
return {
|
||||
docChanged: this.viewUpdate.docChanged,
|
||||
viewportChanged: this.viewUpdate.viewportChanged,
|
||||
selectionSet: this.viewUpdate.selectionSet,
|
||||
focusChanged: this.viewUpdate.focusChanged,
|
||||
geometryChanged: this.viewUpdate.geometryChanged,
|
||||
heightChanged: this.viewUpdate.heightChanged,
|
||||
viewportMoved: this.viewUpdate.viewportMoved,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PostProcessorContext {
|
||||
public readonly mdCtx: MarkdownPostProcessorContext;
|
||||
|
||||
/** The HTML part that is currently being processed. Likely not attached to the DOM. */
|
||||
public readonly element: HTMLElement;
|
||||
|
||||
constructor(element: HTMLElement, markdownPostProcessorContext: MarkdownPostProcessorContext) {
|
||||
this.element = element;
|
||||
this.mdCtx = markdownPostProcessorContext;
|
||||
}
|
||||
|
||||
public equals(other: PostProcessorContext): boolean {
|
||||
if (this === other) return true;
|
||||
|
||||
if (!this.equalsDom(other)) return false;
|
||||
if (this.mdCtx.sourcePath !== other.mdCtx.sourcePath) return false;
|
||||
if (this.mdCtx.docId !== other.mdCtx.docId) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public equalsDom(other: PostProcessorContext): boolean {
|
||||
return this.element === other.element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parent Specify to check whether {@link element} is attached to this specific parent. If `null` method returns false.
|
||||
* @returns `true` if {@link element} is "connected (directly or indirectly) to a `Document` object".
|
||||
*/
|
||||
public isElementAttached(parent?: HTMLElement | null) {
|
||||
if (parent === null || !this.element.isConnected)
|
||||
return false;
|
||||
return parent === undefined || isDescendantOrEqual(parent, this.element);
|
||||
}
|
||||
}
|
||||
321
src/processing/ProcessingPass.ts
Normal file
321
src/processing/ProcessingPass.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { CacheManager, CacheRequest } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { EditorViewPlugin, EditorViewPluginInfo } from "processing/EditorViewPlugin";
|
||||
import { HtmlAssistant, HTMLElementCacheState } from "processing/HtmlAssistant";
|
||||
import { Logger } from "processing/Logger";
|
||||
import { ProcessingContext } from "processing/ProcessingContext";
|
||||
import { Workarounds } from "processing/Workarounds";
|
||||
import { CodeMirrorAssistant } from "utils/CodeMirrorAssistant";
|
||||
import { ObsViewMode } from "utils/ObsAssistant";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
type FetchedSrc = string | null;
|
||||
|
||||
export class ProcessingPass {
|
||||
|
||||
public readonly ctx: ProcessingContext;
|
||||
|
||||
public constructor(ctx: ProcessingContext) {
|
||||
Env.dev.assert(ctx.vuCtx !== null || ctx.ppCtx !== null);
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
public static createViewUpdateLogger() {
|
||||
return new Logger(Env.log.edit, this.updatePassID++, Env.dev.icon.EDIT_UPDATE_PASS);
|
||||
}
|
||||
private static updatePassID: number = 0;
|
||||
|
||||
public static createPostProcessorLogger() {
|
||||
return new Logger(Env.log.read, this.postProcessorPassID++, Env.dev.icon.POST_PROCESS_PASS);
|
||||
}
|
||||
private static postProcessorPassID: number = 0;
|
||||
|
||||
public end(cacheManager: CacheManager) {
|
||||
this.ctx.logr.log(this.ctx.logr.endMsg("ReadOnly:", !this.ctx.isCacheAccessReadWrite()));
|
||||
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
const options = {
|
||||
preventReleases: this.ctx.isInPostProcessingPass,
|
||||
requestsToIgnore: this.requestsToIgnore,
|
||||
logger: this.ctx.logr,
|
||||
};
|
||||
|
||||
cacheManager.updateRetainedCaches(Object.values(this.requestsToRetain), this.ctx.associatedFile.path, options).then(() => {
|
||||
this.ctx.logr.log(this.ctx.logr.msg("\tCalling saveMetadataIfDirty"));
|
||||
cacheManager.saveMetadataIfDirty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static nextPostProcessorPassID() {
|
||||
return this.postProcessorPassID++;
|
||||
}
|
||||
|
||||
public retainCache(urlOrCache: string | CacheRequest) {
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
Env.assert(Env.str.nonEmpty(urlOrCache) !== undefined || Env.obj.is(urlOrCache));
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg(`retainCache: Will retain ${CacheManager.createCacheKeyFromOriginalSrc(Env.str.is(urlOrCache) ? urlOrCache : urlOrCache.source)} at the end of pass`)));
|
||||
|
||||
if (Env.str.is(urlOrCache)) {
|
||||
Env.assert(urlOrCache.length > 0);
|
||||
this.requestsToRetain[urlOrCache] = CacheManager.createRequest(urlOrCache, this.ctx.associatedFile.path);
|
||||
}
|
||||
else {
|
||||
this.requestsToRetain[urlOrCache.source] = urlOrCache;
|
||||
}
|
||||
}
|
||||
}
|
||||
private requestsToRetain: Record<string, CacheRequest> = {};
|
||||
|
||||
public ignoreCache(src: string) {
|
||||
if (this.ctx.isCacheAccessReadWrite()) {
|
||||
Env.assert(Env.str.nonEmpty(src) !== undefined);
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg(`ignoreCache: Will ignore ${CacheManager.createCacheKeyFromOriginalSrc(src)} at the end of pass`)));
|
||||
|
||||
this.requestsToIgnore.add(CacheManager.createRequest(src, this.ctx.associatedFile.path));
|
||||
}
|
||||
}
|
||||
private requestsToIgnore = new Set<CacheRequest>();
|
||||
|
||||
/**
|
||||
* `currentDOM` only includes nodes visible in the viewport (plus a margin).
|
||||
* This method makes sure the remaining images in the note are retained to prevent them from being deleted.
|
||||
*
|
||||
* @param imageElementsInDom Images in viewport/DOM returned by {@link findRelevantImagesToProcessViewUpdate} that have **already been canceled**.
|
||||
* @returns
|
||||
*/
|
||||
public handleImagesNotInCurrentDOM(imageElementsInDom: HTMLImageElement[]) {
|
||||
Env.log.d("ProcessingPass:handleImagesNotInCurrentDOM");
|
||||
this.ctx.assertViewUpdateContext();
|
||||
|
||||
const imgSrcInDom = imageElementsInDom.map(el => {
|
||||
Env.dev.assert(HtmlAssistant.cacheState(el) !== HTMLElementCacheState.ORIGINAL);
|
||||
return HtmlAssistant.originalSrc(el, false);
|
||||
});
|
||||
|
||||
const imagesOutsideViewport = CodeMirrorAssistant.findAllImages(this.ctx.vuCtx.viewUpdate.view, (url) => {
|
||||
const normalizedUrl = Url.normalizeUrl(url);
|
||||
if (imgSrcInDom.includes(normalizedUrl))
|
||||
return false;
|
||||
return Url.isValidExternalUrl(normalizedUrl); // These urls might be local.
|
||||
});
|
||||
|
||||
this.ctx.logr.log(this.ctx.logr.t(() => this.ctx.logr.msg("Found " + imagesOutsideViewport?.length + " images outside viewport: " + imagesOutsideViewport?.map(f => f.src).join(", "))));
|
||||
imagesOutsideViewport?.forEach(image => this.retainCache(image.src));
|
||||
}
|
||||
|
||||
public static findRelevantImagesToProcessInPostProcessor(element: HTMLElement | null, requireSrcAttribute: boolean): { elementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[] } {
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
const elementsToProcess: HTMLImageElement[] = [];
|
||||
|
||||
if (element !== null) {
|
||||
const imageElements = HtmlAssistant.findAllImageElements(element, requireSrcAttribute);
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => {
|
||||
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
|
||||
// Edge case, e.g.: `<img>`
|
||||
if (src === null && HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL)
|
||||
return false;
|
||||
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement) || HtmlAssistant.isImageToProcess(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
const src = ProcessingPass.src(imageElement);
|
||||
if (imagesToCancelFilter(imageElement, src))
|
||||
elementsToProcess.push(imageElement);
|
||||
else if (src !== null)
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
}
|
||||
|
||||
return { elementsToProcess, remainingElements };
|
||||
}
|
||||
|
||||
public static findRelevantImagesToProcessViewUpdate(ctx: ProcessingContext): { elementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[] } {
|
||||
ctx.assertViewUpdateContext();
|
||||
|
||||
// Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements.
|
||||
const imageElements = HtmlAssistant.findAllImageElements(ctx.getPreferredContainerElFromViewUpdate(), false);
|
||||
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(ctx.vuCtx.viewUpdate);
|
||||
|
||||
const elementsToProcess: HTMLImageElement[] = [];
|
||||
const remainingElements: HTMLImageElement[] = [];
|
||||
|
||||
const imagesToCancelFilter = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => {
|
||||
// This filter should work regardless of whether the `src` attr. has been removed, or set to an icon, or is empty.
|
||||
// Make jugements based on state.
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
|
||||
// Edge case, e.g.: `<img>`
|
||||
if (src === null && HtmlAssistant.cacheState(imageElement) === HTMLElementCacheState.ORIGINAL)
|
||||
return false;
|
||||
|
||||
// 2. If there's no src there's nothing left to do but to remove all states that have passed this stage already.
|
||||
if (src === null)
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement);
|
||||
|
||||
// 3. Check if this image element should be ignored.
|
||||
if (!Workarounds.handleInvalidImageElements(sourcesToIgnore, imageElement, src))
|
||||
return false;
|
||||
|
||||
// 3. Only external urls are relevant.
|
||||
// If not negating and returning false, local urls won't be cached here
|
||||
if (!HtmlAssistant.isImageToProcess(imageElement))
|
||||
return false;
|
||||
|
||||
// 4. At this stage filtering based on urls should be done, and here, just look at states to remove all states that have passed this stage already.
|
||||
return ProcessingPass.filterIrrelevantCacheStates(imageElement);
|
||||
};
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
|
||||
const src = ProcessingPass.src(imageElement);
|
||||
|
||||
// 1. First check if a new URL was set on an existing element so that it will be canceled.
|
||||
ProcessingPass.checkIfUrlChanged(ctx, imageElement, src);
|
||||
|
||||
if (imagesToCancelFilter(imageElement, src))
|
||||
elementsToProcess.push(imageElement);
|
||||
else if (src !== null)
|
||||
remainingElements.push(imageElement);
|
||||
}
|
||||
|
||||
return { elementsToProcess, remainingElements };
|
||||
}
|
||||
|
||||
private static src = (imageElement: HTMLImageElement, availableSrc?: FetchedSrc) => availableSrc === undefined ? HtmlAssistant.getSrc(imageElement) : availableSrc;
|
||||
|
||||
/**
|
||||
* Detects whether the url of an already existing image elemetent was changed by the user, and if so, treats the image element as newly added.
|
||||
*
|
||||
* - Should be initial processing step in the editor listener (or at least before canceling starts).
|
||||
* - Note: This must be the first thing that happens since the state is reset, i.e., it effectively aborts any actions that would've been taken on other states.
|
||||
* - That said, the previous element might have been deleted by the system and a new element created, in which case this method does nothing.
|
||||
*/
|
||||
private static checkIfUrlChanged(ctx: ProcessingContext, imageElement: HTMLImageElement, availableSrc?: FetchedSrc) {
|
||||
ctx.assertViewUpdateContext();
|
||||
|
||||
const src = ProcessingPass.src(imageElement, availableSrc);
|
||||
const changed = ctx.vuCtx.viewUpdate.docChanged && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL && Url.isValidExternalUrl(src);
|
||||
|
||||
Env.log.d(ctx.logr.msg("ProcessingPass:checkIfUrlChanged", changed));
|
||||
|
||||
// These all come together to reveal that the src has changed and thus is treated as a new image.
|
||||
// - `update.docChanged`: user edited
|
||||
// - `HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL`: Only unprocessed/"original" image elements have `src` set to external urls...
|
||||
// - `Logic.isValidExternalUr`: ...but this one *has* such url.
|
||||
// Therefore the src was modified, which is tantamount to a separate, added image element, so the state need to be reset and it will be treated as such.
|
||||
// The cache reference will be released as it is not retained now.
|
||||
|
||||
if (changed) {
|
||||
ctx.logr.log(ctx.logr.t(() => ctx.logr.msg(`Url changed on image from ${HtmlAssistant.originalSrc(imageElement)} to ${src}`)));
|
||||
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove elements with states: requesting, downloading, done, and invalid.
|
||||
*
|
||||
* - Those done are already pointing to the cached resource. No need to do anything more.
|
||||
* - Those that are downloading will be handled as the download finishes as part of a previous pass.
|
||||
*
|
||||
* Keeps (returns `true` for)
|
||||
*
|
||||
* - Original: these need to be cancelled
|
||||
* - Cancelled and Failed: these need to request cache.
|
||||
*
|
||||
* **This method does not touch the `src` attribute, it only looks at the states.**
|
||||
*
|
||||
* @param imageElement
|
||||
* @returns `false` if state of {@link imageElement} is one of the above mentioned irrelevant states.
|
||||
*/
|
||||
public static filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
Env.log.d("ProcessingPass:filterIrrelevantCacheStates");
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [HTMLElementCacheState.ORIGINAL, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED]);
|
||||
|
||||
// Difference between this and the above is that ORIGINAL matches all unprocessed elements.
|
||||
return !HtmlAssistant.isCacheStateEqual(state,
|
||||
HTMLElementCacheState.REQUESTING,
|
||||
HTMLElementCacheState.REQUESTING_DOWNLOADING,
|
||||
HTMLElementCacheState.CACHE_SUCCEEDED,
|
||||
HTMLElementCacheState.INVALID
|
||||
);
|
||||
}
|
||||
|
||||
/** Makes sure that elements requesting cache, waiting for download, or already cached, are not released when {@link end} is called. */
|
||||
public handleRequestingAndSucceeded(imageElements: HTMLImageElement[]) {
|
||||
Env.log.d("ProcessingPass:handleRequestingAndSucceeded:", imageElements);
|
||||
|
||||
for (const imageElement of imageElements) {
|
||||
// As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained.
|
||||
if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.retainCache(src);
|
||||
}
|
||||
|
||||
if (HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.REQUESTING, HTMLElementCacheState.REQUESTING_DOWNLOADING)) {
|
||||
const src = HtmlAssistant.originalSrc(imageElement);
|
||||
Env.assert(src !== null, "Expected original source dataset");
|
||||
if (src)
|
||||
this.ignoreCache(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static abortIfMode(ctx: ProcessingContext, ...modes: ObsViewMode[]) {
|
||||
for (const mode of modes) {
|
||||
if (ctx.viewingMode === mode) {
|
||||
ctx.logr.log(ctx.logr.abortMsg("Viewing mode:", ctx.viewingMode));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Must be called **before any other aborts** because this method simply looks at `info.seqNum === 0` to catch the first update call (rather than using a separate bool `info.seqNum < info.newFileDetectedAtSeqNum`). */
|
||||
public static abortIfInvalidContext(ctx: ProcessingContext, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
|
||||
if (ctx.associatedFile === null) {
|
||||
ctx.logr.log(ctx.logr.msg("abortIfInvalidContext: no file, continuing as a read only pass"));
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.assertViewUpdateContext();
|
||||
const editorView = ctx.vuCtx.viewUpdate.view;
|
||||
|
||||
const data = plugin.getViewMetadata(editorView);
|
||||
const associatedFilePath = ctx.associatedFile.path;
|
||||
const viewFilePath = data.requesterPath;
|
||||
|
||||
// A change has definitely occured in that another path was set.
|
||||
if (viewFilePath !== associatedFilePath) {
|
||||
// As view update calls are handled by CodeMirror and the file path retreived from Obsidian API,
|
||||
// the `contentDOM` of the `EditorView` might not yet have been updated to reflect the content if the new
|
||||
// file that is about to be displayed.
|
||||
// Therefore, continuing in this state would cause dom elements to be associated with the wrong file
|
||||
// in the few update events that occurs until the `contentDOM` reflects the content of the new file.
|
||||
// The results in cache items being removed and downloaded until the state stabalizes again (i.e. `contentDOM` = file content).
|
||||
|
||||
// However, each time a new file is initiated with the EditorView, the current view plugin is destroyed and a new instantiated.
|
||||
// The first view update of a fresh view plugin has been shown to contain the `contentDOM` reflecting the file.
|
||||
|
||||
if (info.seqNum === 0) {
|
||||
ctx.logr.log(ctx.logr.msg("analyzeViewUpdate: New file detected."));
|
||||
data.requesterPath = associatedFilePath;
|
||||
plugin.setViewMetadata(editorView, data);
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
ctx.logr.log(ctx.logr.msg("analyzeViewUpdate: waiting..., current seq num:", info.seqNum));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
108
src/processing/Workarounds.ts
Normal file
108
src/processing/Workarounds.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { Env } from "Env";
|
||||
import { HtmlAssistant, HTMLElementCacheState } from "processing/HtmlAssistant";
|
||||
import { Url } from "utils/Url";
|
||||
|
||||
export class Workarounds {
|
||||
/**
|
||||
* If there is a Markdown link at any point after an empty embedded syntax
|
||||
* (i.e., "\!\[\]\(\)" or "\!\[\]\(") an `img` element will be insterted with the `src` attribute
|
||||
* set to the `href` of the link!
|
||||
*
|
||||
* Therefore, when the user types "\!\[\]\(\)" the plugin will start downloading the `href` url.
|
||||
*
|
||||
* Outstanding related issues:
|
||||
*
|
||||
* - As this method only looks at changes, if a note is loaded containing "\!\[\]\(\)" or "\!\[\]\(") with a Markdown link after it, it slips through.
|
||||
* - When note contain "\!\[\]\(", moving the cursor around seems to reset the inserted `img`, which will trigger download.
|
||||
* - If the Markdown link is modified while there is a "\!\[\]\(\)" or "\!\[\]\(" above it.
|
||||
* - If there is a code block after "\!\[\]\(\)" or "\!\[\]\(" containing a Markdown link this link will not cause the issue to arise, but the regex matches with that link rather than any link after the code block which is the real cause.
|
||||
* Pursuing this particular edge case is not worth it because it will likely include to regex searches to discard Markdown links in code blocks.
|
||||
*
|
||||
* The additional processing in these rare edge cases might not be worth addressing.
|
||||
* The important part is to prevent downloads when the user is engaging with the link.
|
||||
*
|
||||
* @param update
|
||||
* @returns The URLs of `img` elements in the DOM that should be ignored or `null` if there are none.
|
||||
*/
|
||||
public static detectSourcesOfInvalidImageElements(update: ViewUpdate) {
|
||||
if (update.changes.empty)
|
||||
return null;
|
||||
|
||||
const sourcesToIgnore: string[] = [];
|
||||
|
||||
update.changes.iterChanges((_fromA, _toA, cursorStartPos, cursorEndPosition, inserted) => {
|
||||
|
||||
const insertedText = inserted.toString();
|
||||
const doc = update.state.doc;
|
||||
const line = doc.lineAt(cursorStartPos);
|
||||
const modifiedLine = doc.sliceString(line.from, line.to) + "\n";
|
||||
|
||||
Env.log.workaround(Env.dev.icon.WORKAROUND, Env.dev.thunkedStr(() => `Text was inserted at pos ${cursorStartPos} - ${cursorEndPosition}: "${insertedText}"`));
|
||||
Env.log.workaround(Env.dev.icon.WORKAROUND, Env.dev.thunkedStr(() => `This line was modified: "${modifiedLine}", match: ${this.embeddedImageRegex.test(modifiedLine)}`));
|
||||
|
||||
if (this.embeddedImageRegex.test(modifiedLine)) {
|
||||
|
||||
// Scan for the first markdown link after the cursor
|
||||
const textAfterCursor = doc.sliceString(cursorEndPosition);
|
||||
const match = this.markdownLinkRegex.exec(textAfterCursor);
|
||||
|
||||
//Env.log.d(`Text after: ${textAfterCursor}`);
|
||||
|
||||
if (match) {
|
||||
const linkUrl = match[1];
|
||||
Env.dev.assert(linkUrl !== undefined && linkUrl.length > 0);
|
||||
if (linkUrl !== undefined && linkUrl.length > 0) {
|
||||
Env.log.workaround(Env.dev.icon.WORKAROUND, Env.dev.thunkedStr(() => `Found image src to ignore: ${linkUrl}`));
|
||||
|
||||
// The image element is only inserted when the link protocol is recognized, i.e.,
|
||||
// when it begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`.
|
||||
// So no need to check if the url is valid or external here. Insert.
|
||||
sourcesToIgnore.push(linkUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sourcesToIgnore.length > 0 ? sourcesToIgnore : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches all cases where the invalid image element is inserted.
|
||||
*
|
||||
* - \!\[\]\(\)
|
||||
* - \!\[\]\(
|
||||
* - \!\[abc\]\(\)
|
||||
* - \!\[\]\(abc
|
||||
*/
|
||||
private static readonly embeddedImageRegex = /!\[(.*?)\]\((.*?)\)?/;
|
||||
|
||||
/**
|
||||
* Use to find the first Markdown link in a string.
|
||||
*
|
||||
* Will not match if there's no link because an img element is not inserted until the link
|
||||
* begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`.
|
||||
*/
|
||||
private static readonly markdownLinkRegex = /(?<!!)\[.*?\]\((.+?)\)/;
|
||||
|
||||
/**
|
||||
* Since image elements are not available in {@link detectSourcesOfInvalidImageElements}, this method is used with the result of that when the elements are available.
|
||||
*
|
||||
* @param sourcesToIgnore Result of having called {@link detectSourcesOfInvalidImageElements}
|
||||
* @param imageElement
|
||||
* @param src The `src` of the {@link imageElement} parameter. Check existance before calling.
|
||||
* @returns `false` when the {@link imageElement}'s state was set to {@link HTMLElementCacheState.INVALID} and should be filtered out of furter processing.
|
||||
*/
|
||||
public static handleInvalidImageElements(sourcesToIgnore: string[] | null, imageElement: HTMLImageElement, src: string): boolean {
|
||||
if (sourcesToIgnore) {
|
||||
for (const sourceToIgnore of sourcesToIgnore) {
|
||||
if (Url.trimBackslash(sourceToIgnore) === Url.trimBackslash(src)) {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.INVALID); // Set to invalid so that the next pass ignores it.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
7
src/types.ts
Normal file
7
src/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// types.ts - Global type declarations
|
||||
|
||||
// Global type aliases
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { App, ButtonComponent, Modal, Setting } from 'obsidian';
|
||||
import { CacheManager } from 'CacheManager';
|
||||
import { PluginSettings } from 'Settings';
|
||||
import { Notice } from 'Environment';
|
||||
import { CacheManager } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { App, ButtonComponent, Modal, Setting } from "obsidian";
|
||||
import { PluginSettings } from "Settings";
|
||||
import { Notice } from "ui/Notice";
|
||||
|
||||
export class InfoModal extends Modal {
|
||||
private cacheManager: CacheManager;
|
||||
|
|
@ -28,7 +29,7 @@ export class InfoModal extends Modal {
|
|||
await cacheManager.clearCached((error) => {
|
||||
if (error) {
|
||||
new Notice(`An error occured while clearing the cache: ${error.message}`, 0);
|
||||
console.error("Error clearing cache.", error);
|
||||
Env.log.e("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
|
|
@ -66,10 +67,10 @@ export class InfoModal extends Modal {
|
|||
|
||||
debugInfoSetting?: Setting;
|
||||
clearCacheSetting: Setting;
|
||||
clearCacheButton: ButtonComponent;
|
||||
closeButton: ButtonComponent;
|
||||
clearCacheButton!: ButtonComponent;
|
||||
closeButton!: ButtonComponent;
|
||||
|
||||
onOpen(): void {
|
||||
override onOpen(): void {
|
||||
super.onOpen();
|
||||
this.cacheManager.registerMetadataChanged(this.onMetadataChangedCallback);
|
||||
setTimeout(() => {
|
||||
|
|
@ -80,7 +81,7 @@ export class InfoModal extends Modal {
|
|||
this.populate();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
override onClose(): void {
|
||||
super.onClose();
|
||||
this.cacheManager.unregisterMetadataChanged(this.onMetadataChangedCallback);
|
||||
}
|
||||
15
src/ui/Notice.ts
Normal file
15
src/ui/Notice.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Env } from "../Env";
|
||||
import { Notice as ObsidianNotice } from "obsidian";
|
||||
|
||||
export class Notice {
|
||||
private innerNotice: ObsidianNotice;
|
||||
|
||||
static NAME: string = Env.str.EMPTY;
|
||||
static setName(name: string) {
|
||||
this.NAME = name;
|
||||
}
|
||||
|
||||
constructor(msg: string, duration?: number, omit: boolean = false) {
|
||||
this.innerNotice = new ObsidianNotice(`${omit ? Env.str.EMPTY : `${Notice.NAME}: `}${msg}`, duration);
|
||||
}
|
||||
}
|
||||
208
src/utils/CodeMirrorAssistant.ts
Normal file
208
src/utils/CodeMirrorAssistant.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||
import { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { Tree } from "@lezer/common";
|
||||
import { Env } from "Env";
|
||||
|
||||
export interface ParsedImage {
|
||||
src: string;
|
||||
alt?: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
type UrlFilter = (url: string) => boolean;
|
||||
|
||||
export class CodeMirrorAssistant {
|
||||
|
||||
public static contentDomFromViewUpdate(viewUpdate: ViewUpdate) {
|
||||
return viewUpdate.view.contentDOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the syntax tree currently available in the editor state covers the entire document.
|
||||
* @returns `true` if the tree is fully parsed, otherwise `false`.
|
||||
*/
|
||||
public static isTreeFullyParsed(view: EditorView): boolean {
|
||||
return syntaxTree(view.state).length === view.state.doc.length;
|
||||
}
|
||||
|
||||
public static findAllImages(view: EditorView, filter?: UrlFilter): ParsedImage[] | null {
|
||||
const tree = CodeMirrorAssistant.ensureSyntaxTree(view);
|
||||
return tree === null ? null : CodeMirrorAssistant.findImagesInTreeRange(view, tree, 0, view.state.doc.length, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds images outside the viewport of the whole document.
|
||||
*
|
||||
* *Note*: Ensures the entire document is parsed before searching. Meaning that if {@link isTreeFullyParsed} returns `false`, this method is slower than {@link findKnownImagesOutsideViewport}.
|
||||
*
|
||||
* @returns `null` if the parser was unable to finish within the specified timeout.
|
||||
*/
|
||||
public static findImagesOutsideViewport(view: EditorView, filter?: UrlFilter): ParsedImage[] | null {
|
||||
const tree = CodeMirrorAssistant.ensureSyntaxTree(view);
|
||||
return tree === null ? null : CodeMirrorAssistant.findImagesOutsideViewportInTree(view, tree, filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only searches the already-parsed parts of the document. For example, It won't find images at the very end of a large, un-scrolled file.
|
||||
*/
|
||||
public static findKnownImagesOutsideViewport(view: EditorView, filter?: UrlFilter) {
|
||||
return CodeMirrorAssistant.findImagesOutsideViewportInTree(view, syntaxTree(view.state), filter);
|
||||
}
|
||||
|
||||
private static findImagesOutsideViewportInTree(view: EditorView, tree: Tree, filter?: UrlFilter): ParsedImage[] {
|
||||
const viewport = view.viewport;
|
||||
|
||||
// Env.log.d(`findImagesOutsideViewportInTree: from: ${viewport.from}, to ${viewport.to}, length: ${view.state.doc.length}`);
|
||||
// Env.log.d(tree.toString());
|
||||
// const now = Env.perf.now();
|
||||
const allImages = CodeMirrorAssistant.findImagesInTreeRange(view, tree, 0, view.state.doc.length, filter);
|
||||
// Env.perf.log(`allImages ${allImages.length}: ${allImages.map(i => i.src).join("\n\t")}`, now);
|
||||
|
||||
//return CodeMirrorAssistant.findImagesInTreeRange(view, tree, viewport.from, viewport.to, filter);
|
||||
|
||||
return allImages.filter(image => {
|
||||
const isFullyOutside = image.to <= viewport.from || image.from >= viewport.to;
|
||||
const isCrossingStart = image.from < viewport.from && image.to > viewport.from;
|
||||
const isCrossingEnd = image.from < viewport.to && image.to > viewport.to;
|
||||
Env.dev.assert(isCrossingStart === false && isCrossingEnd === false)
|
||||
return isFullyOutside || isCrossingStart || isCrossingEnd;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param view
|
||||
* @param tree Tree to search within.
|
||||
* @param from
|
||||
* @param to If position is greater than the length of the currently parsed tree (`tree.length`), it does not cause an error. The iterator will start at {@link from} and stop when it reaches the end of the parsed tree content, even if that is short of this position.
|
||||
* @returns
|
||||
*/
|
||||
private static findImagesInTreeRange(view: EditorView, tree: Tree, from: number, to: number, filter?: UrlFilter): ParsedImage[] {
|
||||
const images: ParsedImage[] = [];
|
||||
|
||||
tree.iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (nodeRef) => {
|
||||
const node = nodeRef.node;
|
||||
|
||||
if (node.name.includes(NodeName.CODEBLOCK_NODE_PART) || node.name.startsWith(NodeName.FRONTMATTER_DEF)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Markdown Image Logic ---
|
||||
if (node.name.startsWith(NodeName.IMAGE_MARKER)) {
|
||||
if (images.some(img => node.from >= img.from && node.to <= img.to)) return;
|
||||
|
||||
let urlNode = null;
|
||||
let altNode = null;
|
||||
let closingParenNode = null;
|
||||
|
||||
let currentNode = node.nextSibling;
|
||||
while (currentNode) {
|
||||
if (currentNode.name.startsWith(NodeName.IMAGE_MARKER)) break;
|
||||
|
||||
const isUrlNode = currentNode.name.endsWith(NodeName.URL) && !currentNode.name.includes(NodeName.FORMATTING);
|
||||
const isAltNode = currentNode.name.startsWith(NodeName.IMAGE_ALT_LINK);
|
||||
|
||||
if (!urlNode && isUrlNode) {
|
||||
urlNode = currentNode;
|
||||
} else if (!altNode && isAltNode) {
|
||||
altNode = currentNode;
|
||||
}
|
||||
|
||||
if (urlNode) {
|
||||
const isUrlFormatting = currentNode.name.startsWith(NodeName.LINK_FORMATTING) && currentNode.name.endsWith(NodeName.URL);
|
||||
const isAfterUrl = currentNode.from > urlNode.from;
|
||||
if (isUrlFormatting && isAfterUrl) {
|
||||
closingParenNode = currentNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentNode = currentNode.nextSibling;
|
||||
}
|
||||
|
||||
if (urlNode && closingParenNode) {
|
||||
const rawUrl = view.state.doc.sliceString(urlNode.from, urlNode.to);
|
||||
const urlParts = rawUrl.split(RegEx.URL_SPLIT);
|
||||
const url = urlParts.length > 0 ? urlParts[0] : undefined;
|
||||
if (url === undefined) return;
|
||||
|
||||
if (filter && !filter(url)) return;
|
||||
|
||||
const alt = altNode ? view.state.doc.sliceString(altNode.from, altNode.to) : "";
|
||||
|
||||
images.push({ src: url, alt, from: node.from, to: closingParenNode.to });
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTML Image Logic ---
|
||||
if (node.name.startsWith(NodeName.HTML_BEGIN_TAG)) {
|
||||
if (images.some(img => node.from >= img.from && node.to <= img.to)) return;
|
||||
|
||||
let endNode = null;
|
||||
let currentNode = node.nextSibling;
|
||||
while(currentNode) {
|
||||
if (currentNode.name.startsWith(NodeName.HTML_END_TAG)) {
|
||||
endNode = currentNode;
|
||||
break;
|
||||
}
|
||||
if (currentNode.name.startsWith(NodeName.HTML_BEGIN_TAG)) break;
|
||||
currentNode = currentNode.nextSibling;
|
||||
}
|
||||
|
||||
if (endNode) {
|
||||
const text = view.state.doc.sliceString(node.from, endNode.to);
|
||||
if (text.includes("<img")) {
|
||||
const srcMatch = text.match(RegEx.HTML_SRC);
|
||||
if (srcMatch && srcMatch[1]) {
|
||||
const url = srcMatch[1];
|
||||
if (filter && !filter(url)) return;
|
||||
|
||||
const altMatch = text.match(RegEx.HTML_ALT);
|
||||
const alt = altMatch?.[1];
|
||||
|
||||
images.push({ src: url, alt, from: node.from, to: endNode.to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private static ensureSyntaxTree(view: EditorView): Tree | null {
|
||||
// 500ms timeout as a safeguard against blocking the UI for too long.
|
||||
const timeout = 500;
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, timeout);
|
||||
|
||||
if (tree === null)
|
||||
Env.log.w(`Timed out parsing full syntax tree for the document (${timeout}ms).`);
|
||||
|
||||
return tree;
|
||||
}
|
||||
}
|
||||
|
||||
const NodeName = {
|
||||
URL: "string_url",
|
||||
URL_FORMATTING: "formatting_formatting-link-string_string_url",
|
||||
IMAGE_MARKER: "formatting_formatting-image_image_image-marker",
|
||||
IMAGE_ALT_LINK_FORMATTING: "formatting_formatting-image_image_image-alt-text_link",
|
||||
IMAGE_ALT_LINK: "image_image-alt-text_link",
|
||||
HTML_BEGIN_TAG: "bracket_hmd-html-begin_tag",
|
||||
HTML_END_TAG: "bracket_hmd-html-end_tag",
|
||||
FRONTMATTER_DEF: "def_hmd-frontmatter",
|
||||
/** All nodes related to a code block seem to have this as part of their name. Thus use `include`: `node.name.includes(NodeName.CODEBLOCK_NODE_PART`. */
|
||||
CODEBLOCK_NODE_PART: "HyperMD-codeblock",
|
||||
FORMATTING: "formatting",
|
||||
LINK_FORMATTING: "formatting_formatting-link-string",
|
||||
} as const;
|
||||
|
||||
// This eliminates the regex compilation overhead on each call. Performance gain might be small, it's still a good practice.
|
||||
const RegEx = {
|
||||
URL_SPLIT: /\s/,
|
||||
HTML_SRC: /src=["']([^"']+)["']/,
|
||||
HTML_ALT: /alt=["']([^"']*)["']/,
|
||||
} as const;
|
||||
19
src/utils/File.ts
Normal file
19
src/utils/File.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Env } from "../Env";
|
||||
|
||||
export class File {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path
|
||||
* @returns `extension` includes the `.`.
|
||||
*/
|
||||
public static getPathInfo(path: string) {
|
||||
const filename = path.split("/").pop() || Env.str.EMPTY;
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
|
||||
const extension = lastDotIndex > 0 ? filename.substring(lastDotIndex) : Env.str.EMPTY;
|
||||
const basename = lastDotIndex > 0 ? filename.substring(0, lastDotIndex) : filename;
|
||||
|
||||
return { filename, basename, extension };
|
||||
}
|
||||
}
|
||||
31
src/utils/Logger.ts
Normal file
31
src/utils/Logger.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Env, LoggerFn } from "Env";
|
||||
|
||||
export class Logger {
|
||||
|
||||
public readonly log: LoggerFn;
|
||||
public readonly id: number;
|
||||
public readonly symbol: string;
|
||||
public readonly t = (action: () => string) => Env.isDev ? action() : Env.str.EMPTY;
|
||||
|
||||
constructor(log: LoggerFn, id: number, symbol: string) {
|
||||
this.log = log;
|
||||
this.id = id;
|
||||
this.symbol = symbol;
|
||||
}
|
||||
|
||||
public get idString() {
|
||||
return "— ID" + this.id;
|
||||
}
|
||||
|
||||
protected joinArgs(args: unknown[]): string {
|
||||
return args.length === 0 ? Env.str.EMPTY : `(${args.join(" ")})`;
|
||||
}
|
||||
|
||||
public msg(...args: unknown[]) {
|
||||
if (!Env.isDev)
|
||||
return Env.str.EMPTY;
|
||||
|
||||
const mappedArgs = args.map(arg => arg === undefined ? "undefined" : arg);
|
||||
return `${this.symbol} ${mappedArgs.join(Env.str.SPACE)} ${this.idString}`;
|
||||
}
|
||||
}
|
||||
278
src/utils/ObsAssistant.ts
Normal file
278
src/utils/ObsAssistant.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { Env } from "Env";
|
||||
import { App, FileView, getIcon, ItemView, MarkdownPostProcessorContext, MarkdownView, TextFileView, TFile, View } from "obsidian";
|
||||
import { Prettify } from "types";
|
||||
import { childEl, firstChildEl, firstParentEl } from "utils/dom";
|
||||
|
||||
export type ObsViewMode = "none" | "reader" | "preview" | "source";
|
||||
|
||||
export type TryGetFirstOptions = {
|
||||
dir: "down" | "up" | "downup" | "updown";
|
||||
element?: HTMLElement;
|
||||
postProcessorContext?: MarkdownPostProcessorContext;
|
||||
view?: View;
|
||||
};
|
||||
|
||||
export type TryGetAllOptions = Prettify<TryGetFirstOptions & {
|
||||
allDescendants: boolean;
|
||||
}>;
|
||||
|
||||
export class ObsAssistant {
|
||||
|
||||
/** Internal API */
|
||||
public static containerElFromPostProcessorContext(postProcessorContext: MarkdownPostProcessorContext) {
|
||||
Env.assert("containerEl" in postProcessorContext, "MarkdownPostProcessorContext does not have `containerEl`");
|
||||
// @ts-expect-error
|
||||
const containerEl = postProcessorContext.containerEl;
|
||||
if (containerEl)
|
||||
return containerEl as HTMLElement;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @returns The first container element found that is the root of a {@link View}. */
|
||||
public static viewContainerEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.WORKSPACE_LEAF_CONTENT, options);
|
||||
}
|
||||
|
||||
/** `<div class="view-content">` */
|
||||
public static viewContentEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.VIEW_CONTENT, options);
|
||||
}
|
||||
|
||||
/** The main reading view container. Sibling to {@link sourceViewEl}. */
|
||||
public static readingViewEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.READING_VIEW, options);
|
||||
}
|
||||
|
||||
public static sourceViewEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.SOURCE_VIEW, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the base class for all popover windows.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <div class="popover hover-popover">`
|
||||
* <div class="markdown-embed is-loaded">
|
||||
* <div class="markdown-embed-content …">
|
||||
* <div class="markdown-preview-view markdown-rendered …">
|
||||
* </div>
|
||||
* …
|
||||
* </div>
|
||||
* </div>
|
||||
* </div>
|
||||
* ```
|
||||
*
|
||||
* @remarks `hover-popover` is a modifier class that extends or specifies the behavior. It indicates the popover appears on hover.
|
||||
*/
|
||||
public static popoverEl(options: TryGetFirstOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.POPOVER, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The container that styles HTML rendered markdown (e.g, with `MarkdownRenderer`) the
|
||||
* standard way it looks in Obsidian's reader view.
|
||||
*
|
||||
* `<div class="markdown-preview-view">`
|
||||
*
|
||||
* Additional classes, such as `markdown-rendered` (content has been processed) or `is-readable-line-width` (when the user has enabled the "Readable line length" setting) might also appear.
|
||||
*
|
||||
* @remarks Do not assume that there's only one of these elements in the DOM. Plugins, like the Kanban plugin for example, might render different content in different small boxes. Each box then contains one of these preview view containers containing the rendered markdown.
|
||||
*/
|
||||
public static previewViewEl(options: TryGetAllOptions) {
|
||||
return ObsAssistant.tryGetEl(Selector.PREVIEW_VIEW, options);
|
||||
}
|
||||
|
||||
private static tryGetEl(selectors: string, options: TryGetFirstOptions | TryGetAllOptions): HTMLElement[] {
|
||||
let element: HTMLElement[] | null = null;
|
||||
|
||||
const findChildOrChildren = (options as TryGetAllOptions).allDescendants === true ? childEl : firstChildEl;
|
||||
const find = (el: HTMLElement) => {
|
||||
|
||||
const orNull = (v: HTMLElement[] | HTMLElement | null): HTMLElement[] | null => v === null
|
||||
? null
|
||||
: (Array.isArray(v) ? v : [v]);
|
||||
|
||||
switch (options.dir) {
|
||||
case "down":
|
||||
return orNull(findChildOrChildren(el, selectors));
|
||||
case "up":
|
||||
return orNull(firstParentEl(el, selectors));
|
||||
case "downup": {
|
||||
let element = orNull(findChildOrChildren(el, selectors));
|
||||
if (element === null)
|
||||
element = orNull(firstParentEl(el, selectors));
|
||||
return element;
|
||||
}
|
||||
case "updown": {
|
||||
let element = orNull(firstParentEl(el, selectors));
|
||||
if (element === null)
|
||||
element = orNull(findChildOrChildren(el, selectors));
|
||||
return element;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (options.view !== undefined) {
|
||||
if (options.view instanceof ItemView)
|
||||
element = find(options.view.contentEl);
|
||||
else
|
||||
element = find(options.view.containerEl);
|
||||
}
|
||||
|
||||
if (element === null && options.postProcessorContext) {
|
||||
const postProcessorContainerEl = ObsAssistant.containerElFromPostProcessorContext(options.postProcessorContext);
|
||||
if (postProcessorContainerEl)
|
||||
find(postProcessorContainerEl);
|
||||
}
|
||||
|
||||
if (element === null && options.element) {
|
||||
find(options.element);
|
||||
}
|
||||
|
||||
return element ?? [];
|
||||
}
|
||||
|
||||
public static viewMode(view: View): ObsViewMode {
|
||||
let vm: ObsViewMode = "none";
|
||||
|
||||
const state = view.getState();
|
||||
const mode = state["mode"];
|
||||
|
||||
if (Env.str.is(mode)) {
|
||||
if (mode === "preview")
|
||||
vm = "reader"
|
||||
else if (mode === "source")
|
||||
vm = Env.bool.isTrue(state["source"]) ? "source" : "preview";
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
public static viewModeFromContainerEl(viewContainerEl: HTMLElement): ObsViewMode {
|
||||
let vm: ObsViewMode = "none";
|
||||
|
||||
const dataMode = ObsAssistant.getAttributeFromContainerEl(viewContainerEl, Attributes.WorkspaceLeaf.DATA_MODE);
|
||||
if (dataMode) {
|
||||
if (dataMode === "preview")
|
||||
vm = "reader";
|
||||
else if (dataMode === "source") {
|
||||
const sourceViewEl = firstChildEl(viewContainerEl, Selector.SOURCE_VIEW);
|
||||
if (sourceViewEl !== null)
|
||||
vm = sourceViewEl.classList.contains("is-live-preview") ? "preview" : "source";
|
||||
}
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
public static viewTypeFromContainerEl(viewContainerEl: HTMLElement) {
|
||||
const type = ObsAssistant.getAttributeFromContainerEl(viewContainerEl, Attributes.WorkspaceLeaf.DATA_TYPE);
|
||||
// file-explorer, markdown, …
|
||||
return type;
|
||||
}
|
||||
|
||||
private static getAttributeFromContainerEl(viewContainerEl: HTMLElement, attribute: string) {
|
||||
if (Env.isDev)
|
||||
Env.dev.assert(viewContainerEl.classList.contains(ClassName.WORKSPACE_LEAF_CONTENT), "Expected element to have class", ClassName.WORKSPACE_LEAF_CONTENT);
|
||||
return viewContainerEl.getAttribute(attribute);
|
||||
}
|
||||
|
||||
/** See also {@link View#getViewType()}. */
|
||||
public static viewClassTypeAsSting(view: View) {
|
||||
if (view instanceof MarkdownView)
|
||||
return "MarkdownView";
|
||||
if (view instanceof TextFileView)
|
||||
return "TextFileView";
|
||||
if (view instanceof FileView)
|
||||
return "FileView";
|
||||
if (view instanceof ItemView)
|
||||
return "ItemView";
|
||||
if (view instanceof View)
|
||||
return "View";
|
||||
Env.dev.assert(false, "Expected View");
|
||||
return "NA";
|
||||
}
|
||||
|
||||
/** @returns If {@link view} extends {@link FileView}, returns the {@link TFile}. */
|
||||
public static getFileFromView(view?: View | null | undefined): TFile | null {
|
||||
if (view === null || view === undefined)
|
||||
return null;
|
||||
// if (view instanceof MarkdownView)
|
||||
// return view.file;
|
||||
if (view instanceof FileView) // MarkdownView, TextFileView, EditableFileView, FileView
|
||||
return view.file;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the view of the specified type that is currently marked as the workspace’s
|
||||
* active view. This is usually the view inside the leaf that receives commands
|
||||
* and hotkeys.
|
||||
*
|
||||
* @returns The active {@link View} instance, or `null` if no matching view is the workspace’s active view.
|
||||
*/
|
||||
public static getActiveView(app: App): View | null {
|
||||
return app.workspace.getActiveViewOfType(View);
|
||||
}
|
||||
|
||||
public static getIcon(iconID: string, options?: { el?: HTMLElement, color?: string, fallbackColor?: string, fallbackIconID?: string }): SVGSVGElement | null {
|
||||
const {
|
||||
el = document.body,
|
||||
color,
|
||||
fallbackColor = "#919191",
|
||||
fallbackIconID,
|
||||
} = options || {};
|
||||
|
||||
let icon = getIcon(iconID);
|
||||
|
||||
Env.assert(icon !== null, "Icon ID not found:", iconID);
|
||||
if (icon === null && fallbackIconID !== undefined)
|
||||
icon = getIcon(fallbackIconID);
|
||||
if (icon === null)
|
||||
return null;
|
||||
|
||||
const iconColor = color ?? getComputedStyle(el).getPropertyValue(CssVar.Icon.COLOR).trim();
|
||||
Env.assert(Env.str.nonEmpty(iconColor) !== undefined, "CSS variable not found:", CssVar.Icon.COLOR);
|
||||
icon.setAttribute("stroke", iconColor || fallbackColor);
|
||||
icon.setAttribute("stroke-width", "1");
|
||||
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
const ClassName = {
|
||||
READING_VIEW: "markdown-reading-view",
|
||||
SOURCE_VIEW: "markdown-source-view",
|
||||
/** The root element of the {@link View}; the {@link View#containerEl}. */
|
||||
WORKSPACE_LEAF_CONTENT: "workspace-leaf-content",
|
||||
VIEW_CONTENT: "view-content",
|
||||
PREVIEW_VIEW: "markdown-preview-view",
|
||||
POPOVER: "popover",
|
||||
} as const;
|
||||
|
||||
const Selector = {
|
||||
READING_VIEW: "." + ClassName.READING_VIEW,
|
||||
/** Either live preview or source code. */
|
||||
SOURCE_VIEW: "." + ClassName.SOURCE_VIEW,
|
||||
PREVIEW_VIEW: "." + ClassName.PREVIEW_VIEW,
|
||||
POPOVER: "." + ClassName.POPOVER,
|
||||
WORKSPACE_LEAF_CONTENT: "." + ClassName.WORKSPACE_LEAF_CONTENT,
|
||||
VIEW_CONTENT: "." + ClassName.VIEW_CONTENT,
|
||||
} as const;
|
||||
|
||||
const CssVar = {
|
||||
/** https://docs.obsidian.md/Reference/CSS+variables/Foundations/Icons */
|
||||
Icon: {
|
||||
COLOR: "--icon-color"
|
||||
} as const,
|
||||
} as const;
|
||||
|
||||
const Attributes = {
|
||||
WorkspaceLeaf: {
|
||||
DATA_TYPE: "data-type",
|
||||
DATA_MODE: "data-mode",
|
||||
}
|
||||
}
|
||||
155
src/utils/Url.ts
Normal file
155
src/utils/Url.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Env } from "../Env";
|
||||
|
||||
export class Url {
|
||||
|
||||
/**
|
||||
* These are case-sensitive. Use with {@link normalizedHeaders}.
|
||||
*/
|
||||
public static readonly RESPONSE_HEADER_LOWERCASE = {
|
||||
contentType: "Content-Type".toLowerCase(),
|
||||
contentLength: "Content-Length".toLowerCase(),
|
||||
cacheControl: "Cache-Control".toLowerCase(),
|
||||
expires: "Expires".toLowerCase(),
|
||||
etag: "ETag".toLowerCase(),
|
||||
lastModified: "Last-Modified".toLowerCase(),
|
||||
} as const;
|
||||
|
||||
public static readonly CACHE_CONTROL_LOWERCASE = {
|
||||
noStore: "no-store",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Normalize headers to make sure to, e.g., find both `Content-Type` and `content-type`.
|
||||
* Also ignores empty strings and trims.
|
||||
*/
|
||||
public static normalizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const normalizedHeaders: Record<string, string> = {};
|
||||
|
||||
for (const key in headers) {
|
||||
const value = headers[key];
|
||||
if (key.length > 0 && value !== undefined) {
|
||||
normalizedHeaders[key.toLowerCase().trim()] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedHeaders;
|
||||
}
|
||||
|
||||
/** @returns A trimmed {@link url} if {@link url} is a non-empty string. */
|
||||
public static normalizeUrl(url: string | null | undefined): string | null {
|
||||
return url?.trim() || null; // If left part evaluates to `undefined` or empty string, the expression `undefined || null` correctly evaluates to `null`.
|
||||
}
|
||||
|
||||
/**
|
||||
* @param src
|
||||
* @param success
|
||||
* @returns `true` if {@link src} is a valid external url, in which case {@link success} will be invoked with the trimmed value; `false` if {@link src} is falsy, a blob, a local reference, etc.
|
||||
*/
|
||||
public static isValidExternalUrl(src: string | null | undefined, success?: (src: string) => void): boolean {
|
||||
if (Env.str.is(src)) {
|
||||
src = src.trim();
|
||||
if (src.length > 0 && Url.isValid(src) && Url.isExternal(src)) {
|
||||
success?.(src);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static isValid(url: string): boolean {
|
||||
return url && URL.canParse(url) ? true : false;
|
||||
}
|
||||
|
||||
/** These are not relevant: ftp, mailto, tel, ws: and wss: */
|
||||
public static isExternal(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static isEmbedded(url: string): boolean {
|
||||
return this.isBlob(url) || url.startsWith("data:");
|
||||
}
|
||||
|
||||
public static isBlob(url: string): boolean {
|
||||
return url.startsWith("blob:"); // Note: no slashes
|
||||
}
|
||||
|
||||
public static isLocal(url: string): boolean {
|
||||
// Slashes are better: e.g., "app:data" or "file:info" are not URLs.
|
||||
return url.startsWith("app://") || url.startsWith("capacitor://") || url.startsWith("file://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ETag header value into a storage-friendly format.
|
||||
* - `W/"abc"` -> `W/abc`
|
||||
* - `"abc"` -> `abc`
|
||||
* @param value The raw ETag header string.
|
||||
* @returns The tag value stripped of quotes, with "W/" prefix preserved if present.
|
||||
* @since 1.1.1
|
||||
*/
|
||||
public static parseETag(value: string | undefined): string | null {
|
||||
if (!Env.str.isNonEmpty(value))
|
||||
return null;
|
||||
|
||||
const match = value.match(/^(W\/)?"(.*)"$/);
|
||||
if (match === null)
|
||||
return null;
|
||||
|
||||
const prefix = match[1] !== undefined ? match[1] : Env.str.EMPTY;
|
||||
const tag = match[2];
|
||||
return `${prefix}${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a stored ETag back to a format suitable for HTTP headers.
|
||||
* - `W/abc` -> `W/"abc"`
|
||||
* - `abc` -> `"abc"`
|
||||
* @param tag The stored ETag value.
|
||||
* @since 1.1.1
|
||||
*/
|
||||
public static stringifyETag(tag: string): string | null {
|
||||
if (Env.str.nonEmpty(tag) === undefined)
|
||||
return null;
|
||||
return tag.replace(/^(W\/)?(.*)$/, '$1"$2"');
|
||||
}
|
||||
|
||||
public static trimBackslash(url: string): string {
|
||||
return url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
/** @author Gemini */
|
||||
public static extractFilenameAndExtension(url: string): { filename: string, extension: string } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
|
||||
if (!pathname) {
|
||||
return null; // No pathname, cannot extract filename
|
||||
}
|
||||
|
||||
const filenameWithExtension = pathname.substring(pathname.lastIndexOf('/') + 1).split('?')[0]; // Remove query params
|
||||
|
||||
if (!filenameWithExtension) {
|
||||
return null; // No filename found
|
||||
}
|
||||
|
||||
const lastDotIndex = filenameWithExtension.lastIndexOf('.');
|
||||
|
||||
if (lastDotIndex === -1) {
|
||||
return { filename: filenameWithExtension, extension: '' }; // No extension
|
||||
}
|
||||
|
||||
const filename = filenameWithExtension.substring(0, lastDotIndex);
|
||||
const extension = filenameWithExtension.substring(lastDotIndex + 1);
|
||||
|
||||
return { filename, extension };
|
||||
} catch (error) {
|
||||
Env.log.e(`Error parsing URL: ${error}`);
|
||||
return null; // Invalid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/utils/dom.ts
Normal file
76
src/utils/dom.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
|
||||
export function firstParentEl(childEl: HTMLElement, selectors: string): HTMLElement | null {
|
||||
return childEl.closest<HTMLElement>(selectors);
|
||||
}
|
||||
|
||||
export function firstChildEl(parentEl: HTMLElement, selectors: string): HTMLElement | null {
|
||||
return parentEl.querySelector<HTMLElement>(selectors);
|
||||
}
|
||||
|
||||
export function childEl(parentEl: HTMLElement, selectors: string): HTMLElement[] {
|
||||
return Array.from(parentEl.querySelectorAll<HTMLElement>(selectors));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a {@link element} is equal to or a descendant of {@link parent}.
|
||||
*
|
||||
* @remarks Traverses from the {@link element}'s position to see if the {@link parent} exists in its chain of ancestors.
|
||||
*
|
||||
* @param parent The potential parent node.
|
||||
* @param element The potential child node. If `null`, `false` is returned.
|
||||
* @returns True if child is a descendant of parent or is the same node.
|
||||
*/
|
||||
export function isDescendantOrEqual(parent: Element, element: Element | null): boolean {
|
||||
return parent.contains(element);
|
||||
}
|
||||
|
||||
let serialQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function queueAsync<T = void>(operation: () => Promise<T>): void {
|
||||
serialQueue = serialQueue
|
||||
.then(() => operation())
|
||||
.catch(err => {
|
||||
console.error('Queue operation failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export function queueAsyncMicrotask<T = void>(operation: () => Promise<T>): void {
|
||||
queueMicrotask(() => {
|
||||
serialQueue = serialQueue
|
||||
.then(operation)
|
||||
.catch(err => {
|
||||
console.error('Microtask failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function sleep(milliseconds: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously waits for an element to be attached to the DOM.
|
||||
* @param element The element to check.
|
||||
* @param timeoutMs The maximum time to wait in milliseconds.
|
||||
* @returns A promise that resolves when the element has a parent, or rejects on timeout.
|
||||
*/
|
||||
export function waitForElementAttachment(element: HTMLElement, timeoutMs = 500, delay = 1, throwOnError = false): Promise<void | Error> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
const check = () => {
|
||||
if (element.parentElement) {
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
const err = new Error(`Element was not attached to the DOM within ${timeoutMs}ms.`);
|
||||
if (throwOnError)
|
||||
reject(err);
|
||||
else
|
||||
resolve(err);
|
||||
} else {
|
||||
setTimeout(check, delay);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
8
src/utils/ts.ts
Normal file
8
src/utils/ts.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
export const Arr = {
|
||||
firstOrNull: <T>(a: Array<T>): T | null => a.first() ?? null,
|
||||
} as const;
|
||||
|
||||
export const Err = {
|
||||
toError: (e: unknown): Error => e instanceof Error ? e : new Error(String(e)),
|
||||
} as const;
|
||||
|
|
@ -1,19 +1,47 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": ["DOM", "ES2022"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
"compilerOptions": {
|
||||
// Enable to check TS7 compatibility.
|
||||
"stableTypeOrdering": false,
|
||||
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"*": ["./src/*"],
|
||||
"#/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
|
||||
"target": "ES2024",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
],
|
||||
"types": ["node"],
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
|
||||
"importHelpers": true,
|
||||
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"erasableSyntaxOnly": false,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
// For ESLint to handle:
|
||||
"allowUnreachableCode": true,
|
||||
"allowUnusedLabels": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"./node_modules",
|
||||
"./dist",
|
||||
"./build",
|
||||
"./main.js",
|
||||
"./eslint.config.js"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
{
|
||||
"1.1.1": "1.8.0",
|
||||
"1.1.0": "1.8.0",
|
||||
"1.0.7": "1.8.0",
|
||||
"1.0.6": "1.8.0",
|
||||
"1.0.5": "1.8.0",
|
||||
"1.0.4": "1.8.0",
|
||||
"1.0.3": "1.8.0",
|
||||
"1.0.2": "1.8.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue