mirror of
https://github.com/mntno/obsidian-come-down.git
synced 2026-07-22 12:20:31 +00:00
Compare commits
No commits in common. "main" and "1.0.0" have entirely different histories.
41 changed files with 2477 additions and 6406 deletions
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"tools": {
|
||||
"autoAccept": false
|
||||
},
|
||||
"context": {
|
||||
"fileName": [
|
||||
"AGENTS.md"
|
||||
],
|
||||
"fileFiltering": {
|
||||
"respectGitIgnore": true,
|
||||
"enableRecursiveFileSearch": true
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"hideBanner": true
|
||||
},
|
||||
"privacy": {
|
||||
"usageStatisticsEnabled": false
|
||||
}
|
||||
}
|
||||
47
.gitignore
vendored
47
.gitignore
vendored
|
|
@ -1,26 +1,21 @@
|
|||
# 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
|
||||
# vscode
|
||||
.vscode
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Hot Reload plugin
|
||||
.hotreload
|
||||
|
||||
main.js
|
||||
cache.json
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
// 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
139
AGENTS.md
|
|
@ -1,139 +0,0 @@
|
|||
# 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
|
||||
23
README.md
23
README.md
|
|
@ -12,7 +12,7 @@ Embed external images as you normally would using a Markdown link. Here’s an e
|
|||
|
||||
When the plugin detects an external image URL, it takes over the downloading process. Once the image is saved, the plugin will automatically load it from the cached file instead of the original URL. The next time you open the note, the cached version will be used.
|
||||
|
||||
If your vault is synced through a file syncing solution like Syncthing or iCloud Drive (which treats your vault as a regular folder with files[^1]), cached images will sync across devices.
|
||||
If your vault is synced through a file-syncing service like iCloud Drive or Dropbox (which treats your vault as a regular folder with files[^1]), cached images will sync across devices.
|
||||
|
||||
However, if your vault is backed up to or synced via Git (e.g., GitHub, GitLab), the cache won’t be included. This is intentional for two reasons: First, Git is designed for versioning plain-text files, not storing binary data like images. Second, hosting services like GitHub and GitLab are meant for managing code and text-based content, not for storing personal images like a photo service.
|
||||
|
||||
|
|
@ -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,12 +35,19 @@ 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 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.
|
||||
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.
|
||||
|
||||
[^1]: This excludes [Obsidian Sync](https://obsidian.md/sync).
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import builtins from "builtin-modules";
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
|
|
@ -12,10 +10,6 @@ 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: {
|
||||
|
|
@ -39,16 +33,15 @@ const context = await esbuild.context({
|
|||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
// Runtime of min supported Obsidian version 1.8.0, see manifest.json
|
||||
target: "es2024",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outdir: outdir,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
define: {
|
||||
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
|
||||
},
|
||||
"process.env.NODE_ENV": prod ? '"production"' : '"development"',
|
||||
},
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
// 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
46
eslint.config.mjs
Normal file
46
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
|
||||
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,10 +1,10 @@
|
|||
{
|
||||
"id": "come-down",
|
||||
"name": "Come Down",
|
||||
"version": "1.1.1",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.8.0",
|
||||
"description": "Maintains a cache of your notes’ embedded external images.",
|
||||
"author": "mntno",
|
||||
"authorUrl": "https://github.com/mntno",
|
||||
"authorUrl": "https://github.com/mntno",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
33
package.json
33
package.json
|
|
@ -4,31 +4,28 @@
|
|||
"description": "An Obsidian plugin for caching embedded external images.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"lint": "pnpm eslint .",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "(pnpm eslint . || true) && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"build": "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/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",
|
||||
"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",
|
||||
"obsidian": "latest",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.1",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.8.2",
|
||||
"typescript-eslint": "8.26.0",
|
||||
"xxhash-wasm": "1.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
|
|
|
|||
2795
pnpm-lock.yaml
2795
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
818
src/CacheManager.ts
Normal file
818
src/CacheManager.ts
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
import { normalizePath, requestUrl, Vault } from "obsidian";
|
||||
import { imageSize } from 'image-size'
|
||||
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.
|
||||
* For an external image file, this would be the url.
|
||||
*/
|
||||
source: string;
|
||||
|
||||
/** @todo Can do without. However, this makes each {@link CacheRequest} unique per requester. */
|
||||
requesterPath: string;
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
|
||||
/** The absolute path to the item, which can vary per platform, e.g., `app://` on desktop but `capacitor://` on mobile. */
|
||||
resourcePath: string;
|
||||
|
||||
metadata: CacheMetadata;
|
||||
|
||||
/** Whether this `item` was fetched from the cache. */
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What you get after having requested a file from the {@link CacheManager}.
|
||||
*/
|
||||
export class CacheResult {
|
||||
/**
|
||||
* @param request
|
||||
* @param cacheKey
|
||||
* @param item
|
||||
* @param error If {@link item} is set, this won't be.
|
||||
* @param fileExists If `undefined`, it's unknown whether the file exists.
|
||||
*/
|
||||
constructor(
|
||||
public readonly request: CacheRequest,
|
||||
public readonly cacheKey: string,
|
||||
public readonly item: CacheItem | null,
|
||||
public readonly error: Error | null,
|
||||
public readonly fileExists?: boolean) { }
|
||||
}
|
||||
|
||||
export class CacheError extends Error {
|
||||
constructor(public readonly cacheKey: string, message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "CacheError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CacheTypeError extends CacheError {
|
||||
constructor(error: TypeError) {
|
||||
super("", error.message, { cause: error });
|
||||
this.name = "CacheTypeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In the event of a cache miss.
|
||||
*/
|
||||
export class CacheNotFoundError extends CacheError {
|
||||
/**
|
||||
* @param cacheKey
|
||||
*/
|
||||
constructor(cacheKey: string, cause?: Error) {
|
||||
super(cacheKey, `Cache not found: ${cacheKey}`, { cause: cause });
|
||||
this.name = "CacheNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CacheFetchError extends CacheError {
|
||||
public readonly isRetryable: boolean = false;
|
||||
public readonly isInternetDisconnected: boolean = false;
|
||||
|
||||
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 });
|
||||
|
||||
if (error) {
|
||||
if (error.message && error.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
|
||||
Log(`Domain name resolution error: ${info.sourceUrl} - ${error.message}`);
|
||||
} else if (error instanceof URIError) {
|
||||
Log("Invalid URL provided.");
|
||||
} else if (error.message && error.message.includes("net::ERR_INTERNET_DISCONNECTED")) {
|
||||
Log("Network error: Could not connect to the server.");
|
||||
this.isRetryable = true;
|
||||
this.isInternetDisconnected = true;
|
||||
} else {
|
||||
Log("Request failed:", error);
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.isRetryable = !this.info?.statusCode || (this.info?.statusCode >= 500 && this.info?.statusCode < 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CacheInfo {
|
||||
summary: string;
|
||||
numberOfFilesCached: number;
|
||||
numberOfActualFilesWithoutAssociatedCacheKey: number;
|
||||
numberOfCacheKeysWithoutAssociatedFile: number;
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
filename: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
export class CacheManager {
|
||||
|
||||
private metadataRoot: CacheRoot;
|
||||
|
||||
private get cache(): Record<string, CacheMetadata> {
|
||||
return this.metadataRoot.items;
|
||||
};
|
||||
|
||||
/** Ongoing downloads. As downloads complete, they will be removed. */
|
||||
private readonly currentDownloads: Map<string, CacheRequest> = new Map();
|
||||
private readonly vault: Vault;
|
||||
/** Root directory where cache is kept. Relative to vault root. */
|
||||
private readonly cacheDir: string;
|
||||
private readonly metadataFilePath: string;
|
||||
|
||||
private static hasher: XXHashAPI;
|
||||
|
||||
private readonly filePathsToOmitWhenClearingCache: string[]
|
||||
|
||||
//#region
|
||||
|
||||
private constructor(vault: Vault, cacheDir: string, metadataFilePath: string, filePathsToOmitWhenClearingCache: string[]) {
|
||||
this.vault = vault;
|
||||
this.cacheDir = cacheDir;
|
||||
this.metadataFilePath = metadataFilePath;
|
||||
this.filePathsToOmitWhenClearingCache = filePathsToOmitWhenClearingCache.includes(metadataFilePath) ? filePathsToOmitWhenClearingCache : [...filePathsToOmitWhenClearingCache, metadataFilePath];
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
await instance.initCache(); // This can be called lazily if it turns out reading the json file takes too long time.
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates {@link cache} and {@link retainers} from JSON file.
|
||||
*/
|
||||
private async initCache() {
|
||||
if (this.cacheInitiated)
|
||||
return;
|
||||
|
||||
if (this.initPromise)
|
||||
return this.initPromise; // If there's an ongoing initialization, wait for it to complete.
|
||||
|
||||
Log(`CacheManager:initCache`);
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
await this.loadMetadata();
|
||||
this.cacheInitiated = true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
this.initPromise = null; // Reset when done.
|
||||
}
|
||||
})();
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
private cacheInitiated = false;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Call before making requests.
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
public validateRequest(request: CacheRequest): Error | undefined {
|
||||
if (request.source.length == 0)
|
||||
return new Error(`The cache key is not set.`);
|
||||
|
||||
if (!Url.isExternal(request.source))
|
||||
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) {
|
||||
|
||||
Log(`CacheManager:retainRequests ${requests.length}\n\tRetainer: ${retainerPath}`);
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[retainerPath];
|
||||
|
||||
const oldCi = retainer ? [...retainer.ref] : [];
|
||||
const newCi: string[] = [];
|
||||
|
||||
for (const request of requests) {
|
||||
const cacheKey = CacheManager.createCacheKeyFromRequest(request);
|
||||
if (!newCi.includes(cacheKey)) {
|
||||
newCi.push(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
const addedReferences = newCi.filter(key => !oldCi.includes(key));
|
||||
const removedReferences = oldCi.filter(key => !newCi.includes(key));
|
||||
|
||||
if (addedReferences.length == 0 && removedReferences.length == 0) {
|
||||
Log(`\tNo change.`)
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMetadataDirty = true;
|
||||
|
||||
// console.log(`Update: ` + newCi);
|
||||
// console.log(`Retaining: ` + addedReferences);
|
||||
// console.log(`Releasing: ` + removedReferences);
|
||||
|
||||
if (retainer) {
|
||||
retainer.ref = newCi;
|
||||
}
|
||||
else {
|
||||
retainer = { ref: newCi }
|
||||
this.metadataRoot.retainers[retainerPath] = retainer;
|
||||
}
|
||||
|
||||
const retainCount = this.retainCount();
|
||||
// console.log(this.retainCount());
|
||||
|
||||
for (const removedReference of removedReferences) {
|
||||
if (removedReference in retainCount) {
|
||||
const cacheKeyRetainCount = retainCount[removedReference];
|
||||
console.assert(cacheKeyRetainCount !== undefined, "");
|
||||
if (cacheKeyRetainCount === 0) {
|
||||
await this.removeCacheItem(removedReference);
|
||||
}
|
||||
}
|
||||
else
|
||||
console.assert("");
|
||||
}
|
||||
|
||||
// If it doesn't refernce anything anymore there's no need to keep it around.
|
||||
if (retainer.ref.length == 0)
|
||||
delete this.metadataRoot.retainers[retainerPath];
|
||||
}
|
||||
|
||||
public renameRetainer(oldPath: string, path: string) {
|
||||
Log(`CacheManager.renameRetainer\n\t${oldPath}`);
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[oldPath];
|
||||
if (retainer) {
|
||||
this.metadataRoot.retainers[path] = retainer;
|
||||
delete this.metadataRoot.retainers[oldPath];
|
||||
this.isMetadataDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
public async removeRetainer(path: string) {
|
||||
Log(`CacheManager.removeRetainer\n\t${path}`);
|
||||
|
||||
let retainer: CacheRetainer = this.metadataRoot.retainers[path];
|
||||
if (!retainer) {
|
||||
// This can happen if a file which hasn't been open was deleted without opening it.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retainCount = this.retainCount(retainer)
|
||||
for (const cacheKey of retainer.ref) {
|
||||
// Release
|
||||
retainCount[cacheKey]--;
|
||||
|
||||
if (retainCount[cacheKey] == 0) {
|
||||
await this.removeCacheItem(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
delete this.metadataRoot.retainers[path];
|
||||
this.isMetadataDirty = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeCacheItem(cacheKey: string) {
|
||||
Log(`CacheManager:removeCacheItem\n\tRemoving ${this.nameOfCachedFileFromMetadata(this.cache[cacheKey])}`);
|
||||
await this.vault.adapter.remove(this.filePathToCachedFileFromMetadata(this.cache[cacheKey]));
|
||||
delete this.cache[cacheKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* First registers all in-memory cache keys (if the {@link byRetainer} parameter is passed, only registers keys referenced by that retainer),
|
||||
* then goes through all in-memory retainers and counts the number of total references
|
||||
* for each registered cache key.
|
||||
*
|
||||
* @param byRetainer
|
||||
* @returns A `Record` where the key is the cache key and the value its retain count.
|
||||
*/
|
||||
public retainCount(byRetainer?: CacheRetainer) {
|
||||
// Get retain counts on all caches used.
|
||||
const retainCounts: Record<string, number> = {};
|
||||
|
||||
if (byRetainer) {
|
||||
for (const cacheKey of byRetainer.ref) {
|
||||
retainCounts[cacheKey] = 0;
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const cacheKey of Object.keys(this.cache)) {
|
||||
retainCounts[cacheKey] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (const retainer of Object.values(this.metadataRoot.retainers)) {
|
||||
for (const cacheKey of retainer.ref)
|
||||
if (cacheKey in retainCounts)
|
||||
retainCounts[cacheKey]++;
|
||||
}
|
||||
|
||||
return retainCounts;
|
||||
}
|
||||
|
||||
public async info(callback: (info: CacheInfo) => void) {
|
||||
|
||||
const cacheKeys = Object.keys(this.cache);
|
||||
|
||||
// Actual files on disk
|
||||
const actualCachedFilePaths = await this.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];
|
||||
let found = false;
|
||||
for (const associatedFilePath of actualCachedFilePaths) {
|
||||
if (associatedFilePath === this.filePathToCachedFileFromMetadata(cacheKeyMetadata, cacheKey)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
cacheKeysWithoutAssociatedFile.push(cacheKey);
|
||||
}
|
||||
|
||||
// There is an actual file on disk but no corresponding cache key.
|
||||
const actualFileWithoutAssociatedCacheKey = [];
|
||||
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)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
actualFileWithoutAssociatedCacheKey.push(actualFilePath.split("/").pop());
|
||||
}
|
||||
|
||||
//
|
||||
const retainers: CacheRetainer[] = Object.values(this.metadataRoot.retainers);
|
||||
const numberOfRetainers = retainers.length;
|
||||
|
||||
// Here the Mardown file has been deleted but its still exists as a retainer.
|
||||
const retainersWithoutActualFile = [];
|
||||
for (const actualFilePath in this.metadataRoot.retainers) {
|
||||
if (!await this.vault.adapter.exists(actualFilePath))
|
||||
retainersWithoutActualFile.push(actualFilePath);
|
||||
}
|
||||
|
||||
const retainersWithoutReferences = [];
|
||||
for (const retainer of retainers) {
|
||||
if (!retainer.ref || retainer.ref.length == 0)
|
||||
retainersWithoutReferences.push(retainer);
|
||||
}
|
||||
|
||||
// There are cache keys that aren't referenced by any retainer.
|
||||
// TODO: Files not marked as retainer
|
||||
const cacheKeysWithoutAnyRetainer: string[] = [];
|
||||
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.
|
||||
cacheKeysWithoutAnyRetainer.push(cacheKey);
|
||||
}
|
||||
|
||||
const numberOfCacheKeysWithoutAssociatedFile = cacheKeysWithoutAssociatedFile.length;
|
||||
const numberOfActualFileWithoutAssociatedCacheKey = actualFileWithoutAssociatedCacheKey.length;
|
||||
|
||||
let summary = "";
|
||||
summary += `Cache items without file: ${numberOfCacheKeysWithoutAssociatedFile}${numberOfCacheKeysWithoutAssociatedFile > 0 ? " 🛑" : ""}${numberOfCacheKeysWithoutAssociatedFile > 0 ? `: ${cacheKeysWithoutAssociatedFile.join(", ")}` : ""}\n`;
|
||||
summary += `Files whithout cache items: ${numberOfActualFileWithoutAssociatedCacheKey}${numberOfActualFileWithoutAssociatedCacheKey > 0 ? " 🛑" : ""}${actualFileWithoutAssociatedCacheKey.length > 0 ? `: ${actualFileWithoutAssociatedCacheKey.join(", ")}` : ""}\n`;
|
||||
summary += `Cache items without retainer: ${cacheKeysWithoutAnyRetainer.length}${cacheKeysWithoutAnyRetainer.length > 0 ? " 🛑" : ""}${cacheKeysWithoutAnyRetainer.length > 0 ? `: ${cacheKeysWithoutAnyRetainer.join(", ")}` : ""}\n\n`;
|
||||
summary += `Retainers without references: ${retainersWithoutReferences.length}${retainersWithoutReferences.length > 0 ? " 🛑" : ""}\n`;
|
||||
summary += `Retainers without file: ${retainersWithoutActualFile.length}${retainersWithoutActualFile.length > 0 ? " 🛑" : ""}\n`;
|
||||
|
||||
callback({
|
||||
summary: summary,
|
||||
numberOfFilesCached: actualCachedFilePaths.length,
|
||||
numberOfCacheKeysWithoutAssociatedFile: numberOfCacheKeysWithoutAssociatedFile,
|
||||
numberOfActualFilesWithoutAssociatedCacheKey: numberOfActualFileWithoutAssociatedCacheKey,
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
/**
|
||||
* Get {@link CacheMetadata} if in cache.
|
||||
* Will do nothing if the request doesn't exist in the cache.
|
||||
* @param request
|
||||
* @param [ignoreMissingFile=false] If set, will return the metadata without checking if the associated cache file actually exists.
|
||||
* @returns Will also return `null` if the actual file does not exist.
|
||||
*/
|
||||
public async existingCache(request: CacheRequest, ignoreMissingFile: boolean = false, cacheKey?: string): Promise<CacheResult> {
|
||||
if (!this.cacheInitiated)
|
||||
await this.initCache()
|
||||
|
||||
cacheKey = cacheKey ?? CacheManager.createCacheKeyFromRequest(request);
|
||||
const metadata = this.cache[cacheKey];
|
||||
if (metadata) {
|
||||
if (ignoreMissingFile || await this.vault.adapter.exists(this.filePathToCachedFileFromMetadata(metadata), true))
|
||||
return new CacheResult(request, cacheKey, this.createCacheItem(metadata, true), null, ignoreMissingFile ? undefined : true);
|
||||
else
|
||||
return new CacheResult(request, cacheKey, null, new CacheNotFoundError(cacheKey), false);
|
||||
}
|
||||
|
||||
return new CacheResult(request, cacheKey, null, new CacheNotFoundError(cacheKey), undefined);
|
||||
}
|
||||
|
||||
private createCacheItem(metadata: CacheMetadata, fromCache: boolean): CacheItem {
|
||||
return {
|
||||
resourcePath: this.vault.adapter.getResourcePath(this.filePathToCachedFileFromMetadata(metadata)),
|
||||
metadata: metadata,
|
||||
fromCache: fromCache
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param request
|
||||
* @param force Download immediately without checking if cache already exists.
|
||||
* @param callback
|
||||
* @returns
|
||||
*/
|
||||
public async getCache(request: CacheRequest, force: boolean, callback: (result: CacheResult) => void): Promise<void> {
|
||||
if (!this.cacheInitiated)
|
||||
await this.initCache();
|
||||
|
||||
const validationError = this.validateRequest(request);
|
||||
if (validationError) {
|
||||
callback(new CacheResult(request, CacheManager.createCacheKeyFromRequest(request), null, validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const sourceFileInfo = Url.extractFilenameAndExtension(request.source) as FileInfo | null;
|
||||
if (!sourceFileInfo)
|
||||
callback(new CacheResult(request, CacheManager.createCacheKeyFromRequest(request), null, new Error(`Failed to extract fileInfo from source url.`)));
|
||||
else
|
||||
callback(await this.fetchNewCache(request, sourceFileInfo));
|
||||
};
|
||||
|
||||
if (force) {
|
||||
await download();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.`)
|
||||
// return null;
|
||||
// }
|
||||
|
||||
const result = await this.existingCache(request);
|
||||
if (result.item) {
|
||||
Log(`CacheManager:getCache\n\tGot cache for cacheKey: ${result.cacheKey}`);
|
||||
callback(result);
|
||||
}
|
||||
else {
|
||||
await download();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a {@link request} does not yield a local result, call this method to download.
|
||||
*
|
||||
* @param request
|
||||
* @param sourceFileInfo
|
||||
* @param callback
|
||||
* @returns
|
||||
*/
|
||||
private async fetchNewCache(request: CacheRequest, sourceFileInfo: FileInfo): Promise<CacheResult> {
|
||||
let cacheKey: string | undefined;
|
||||
let result: CacheResult | undefined;
|
||||
|
||||
try {
|
||||
cacheKey = CacheManager.createCacheKeyFromRequest(request);
|
||||
// TODO: Multiple Files Waiting Requesting the Same Cache
|
||||
this.currentDownloads.set(cacheKey, request);
|
||||
|
||||
result = await this.download(request, sourceFileInfo);
|
||||
}
|
||||
catch (error) {
|
||||
result = new CacheResult(request, cacheKey ?? "", null, error);
|
||||
}
|
||||
finally {
|
||||
if (cacheKey)
|
||||
this.currentDownloads.delete(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire cache.
|
||||
*
|
||||
* @param filePathsToOmit Array of normalized paths to files to not delete from disk.
|
||||
*/
|
||||
async clearCached(callback?: (error?: Error) => void) {
|
||||
try {
|
||||
await this.cancelAllOngoing();
|
||||
|
||||
for (const filePath of await this.actualCachedFilePaths()) {
|
||||
if (!this.filePathsToOmitWhenClearingCache.includes(filePath))
|
||||
await this.vault.adapter.remove(filePath);
|
||||
}
|
||||
|
||||
await this.resetMetadata();
|
||||
|
||||
callback?.();
|
||||
} catch (error) {
|
||||
callback?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
public async actualCachedFilePaths() {
|
||||
const listed = await this.vault.adapter.list(this.cacheDir);
|
||||
return listed
|
||||
.files
|
||||
.filter((filePath) => !this.filePathsToOmitWhenClearingCache.includes(filePath))
|
||||
}
|
||||
|
||||
public debug() {
|
||||
return {
|
||||
loadMetadata: async () => await this.loadMetadata(),
|
||||
saveMetadata: async () => await this.saveMetadata(),
|
||||
};
|
||||
}
|
||||
|
||||
public async loadMetadata() {
|
||||
const metadataFileExists = await this.vault.adapter.exists(this.metadataFilePath, true);
|
||||
if (metadataFileExists) {
|
||||
const metadataFileContent: string = await this.vault.adapter.read(this.metadataFilePath);
|
||||
try {
|
||||
this.metadataRoot = Object.assign({}, EMPTY_CACHE_ROOT, JSON.parse(metadataFileContent));
|
||||
} catch (error) {
|
||||
console.error("Failed to read metadata. Clearing cache.", error);
|
||||
await this.clearCached();
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.resetMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empties {@link metadataRoot} and its associated file at {@link metadataFilePath}.
|
||||
* If they don't exist, they will be created.
|
||||
*/
|
||||
private async resetMetadata() {
|
||||
this.metadataRoot = Object.assign({}, EMPTY_CACHE_ROOT);
|
||||
await this.saveMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
this.isMetadataDirty = false;
|
||||
}
|
||||
|
||||
public saveMetadataIfDirty(): Promise<void> | null {
|
||||
Log(`CacheManager: saveMetadataIfDirty ${this.isMetadataDirty}`);
|
||||
return this.isMetadataDirty ? this.saveMetadata() : null;
|
||||
}
|
||||
private isMetadataDirty: boolean = false;
|
||||
|
||||
/**
|
||||
* Aborts current download requests for the specified file.
|
||||
* @todo
|
||||
*/
|
||||
async cancelOngoing(filePath: string) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts all current download requests.
|
||||
* @todo
|
||||
*/
|
||||
async cancelAllOngoing() {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
public static createCacheKeyFromOriginalSrc(src: string) {
|
||||
return this.hashString(src);
|
||||
}
|
||||
|
||||
private static hashString(text: string) {
|
||||
return CacheManager.hasher.h64ToString(text.trim());
|
||||
}
|
||||
|
||||
private static hashBinary(bytes: Uint8Array) {
|
||||
return CacheManager.hasher.h64Raw(bytes).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a key from the {@link request}.
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
private static createCacheKeyFromRequest(request: CacheRequest) {
|
||||
return CacheManager.createCacheKeyFromOriginalSrc(request.source)
|
||||
}
|
||||
|
||||
public static createCacheKeyFromMetadata(metadata: CacheMetadata) {
|
||||
return CacheManager.createCacheKeyFromOriginalSrc(metadata.f.s);
|
||||
}
|
||||
|
||||
// private filePathToAssociatedMetadata(request: CacheRequest) {
|
||||
// return normalizePath(`${this.cacheDir}/${CacheManager.createCacheKey(request)}.json`);
|
||||
// }
|
||||
|
||||
private filePathToCachedFile(request: CacheRequest, extension: string) {
|
||||
return normalizePath(`${this.cacheDir}/${CacheManager.createCacheKeyFromRequest(request)}${extension.length > 0 ? `.${extension}` : ``}`);
|
||||
}
|
||||
|
||||
public filePathToCachedFileFromMetadata(metadata: CacheMetadata, cacheKey?: string) {
|
||||
return normalizePath(`${this.cacheDir}/${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param metadata
|
||||
* @param cacheKey Supply the cache key if you have it to avoid generating it again.
|
||||
* @returns
|
||||
*/
|
||||
private nameOfCachedFileFromMetadata(metadata: CacheMetadata, cacheKey?: string) {
|
||||
return `${cacheKey ?? CacheManager.createCacheKeyFromMetadata(metadata)}${metadata.f.e.length > 0 ? `.${metadata.f.e}` : ``}`;
|
||||
}
|
||||
|
||||
private fileInfoFromMetadata(metadata: CacheMetadata): FileInfo {
|
||||
return { filename: metadata.f.n, extension: metadata.f.e };
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Downloads the {@link request}
|
||||
* 2. Creates its {@link CacheMetadata|metadata}
|
||||
* 3. Writes downloaded item to disk. Tries to roll back changes on failure.
|
||||
*
|
||||
* @param request
|
||||
* @param fileInfo
|
||||
* @returns
|
||||
*/
|
||||
private async download(request: CacheRequest, fileInfo: FileInfo): Promise<CacheResult> {
|
||||
|
||||
let result: CacheResult;
|
||||
const sourceUrl = request.source;
|
||||
const cacheKey = CacheManager.createCacheKeyFromRequest(request);
|
||||
|
||||
try {
|
||||
Log(`CacheManager:download: Requesting ${cacheKey} ⬇️⬇️⬇️\n\t...${sourceUrl.slice(-50)}`);
|
||||
|
||||
if (ENV.dev)
|
||||
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;
|
||||
|
||||
//Log(`CacheManager:download: Got response:\n\tcacheID: ${cacheKey}\n\t${response.status}\n\tcontentType: ${contentType}`);
|
||||
|
||||
if (cacheControl == Url.CACHE_CONTROL_LOWERCASE.noStore)
|
||||
throw new CacheError(`Caching not allowed on ${sourceUrl}.`, cacheKey);
|
||||
|
||||
if (response.status >= 400)
|
||||
throw new CacheFetchError(cacheKey, null, { sourceUrl: sourceUrl, statusCode: response.status });
|
||||
|
||||
const bytes = new Uint8Array(response.arrayBuffer);
|
||||
const imageMetadata = this.handleImage(bytes); // Will throw if what was downloaded isn't a supported image type. This is checked before the file is written.
|
||||
const nowDateString = new Date().toISOString();
|
||||
const cacheItemPath = this.filePathToCachedFile(request, fileInfo.extension);
|
||||
|
||||
try {
|
||||
await this.vault.adapter.writeBinary(cacheItemPath, response.arrayBuffer);
|
||||
}
|
||||
catch (writeError) {
|
||||
try {
|
||||
await this.vault.adapter.remove(cacheItemPath);
|
||||
} catch (removeError) {
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to remove cached file after write error:", removeError);
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
ty: CacheType.IMAGE,
|
||||
ti: {
|
||||
d: nowDateString,
|
||||
l: nowDateString,
|
||||
cc: cacheControl,
|
||||
},
|
||||
f: {
|
||||
s: sourceUrl,
|
||||
n: fileInfo.filename,
|
||||
e: fileInfo.extension,
|
||||
sz: response.arrayBuffer.byteLength,
|
||||
ct: contentType,
|
||||
ch: CacheManager.hashBinary(bytes),
|
||||
},
|
||||
i: imageMetadata
|
||||
};
|
||||
|
||||
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)}`);
|
||||
} catch (error) {
|
||||
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to write cache metadata:", error);
|
||||
|
||||
// File write failed. Rollback the cache and remove the potentially created file.
|
||||
delete this.cache[cacheKey];
|
||||
|
||||
try {
|
||||
await this.vault.adapter.remove(cacheItemPath);
|
||||
} catch (removeError) {
|
||||
if (ENV.debugLog)
|
||||
console.error("Failed to remove cached file after write error:", removeError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof CacheError)
|
||||
result = new CacheResult(request, cacheKey, null, error);
|
||||
else
|
||||
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, error, { sourceUrl: sourceUrl }));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses image metadata such as width and height.
|
||||
*
|
||||
* @param byteArray The image data as a `Uint8Array`.
|
||||
* @returns The extracted image metadata.
|
||||
* @throws {CacheTypeError} If the image type is unsupported.
|
||||
* @throws {CacheError} If another error occurs while reading the image.
|
||||
*/
|
||||
private handleImage(byteArray: Uint8Array): CacheMetadataImage {
|
||||
|
||||
try {
|
||||
const sizeCalcResult = imageSize(byteArray);
|
||||
|
||||
// https://github.com/image-size/image-size#jpeg-image-orientation
|
||||
return {
|
||||
w: sizeCalcResult.width,
|
||||
h: sizeCalcResult.height,
|
||||
t: sizeCalcResult?.type ?? "",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
throw new CacheTypeError(error);
|
||||
else
|
||||
throw new CacheError("", "Failed to read image.", { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
export interface CacheRoot {
|
||||
retainers: Record<string, CacheRetainer>;
|
||||
retainers: Record<string, CacheRetainer>
|
||||
items: Record<string, CacheMetadata>;
|
||||
}
|
||||
|
||||
export const EMPTY_CACHE_ROOT: CacheRoot = {
|
||||
retainers: {},
|
||||
retainers: {},
|
||||
items: {},
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* All files that reference a {@link CacheMetadata}.
|
||||
* The cache should only be remove when there are no "retainers".
|
||||
*/
|
||||
|
|
@ -43,9 +43,9 @@ export interface CacheMetadataFile {
|
|||
/** name */
|
||||
n: string;
|
||||
|
||||
/**
|
||||
/**
|
||||
* ext
|
||||
* If empty string, then no extension.
|
||||
* If empty string, then no extension.
|
||||
*/
|
||||
e: string;
|
||||
|
||||
|
|
@ -71,36 +71,13 @@ export interface CacheMetadataImage {
|
|||
|
||||
/** Time related */
|
||||
export interface CacheMetadataTime {
|
||||
|
||||
|
||||
/** Download time */
|
||||
d: string;
|
||||
|
||||
/**
|
||||
* "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.
|
||||
*/
|
||||
|
||||
/** Last accessed */
|
||||
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;
|
||||
}
|
||||
}
|
||||
120
src/Env.ts
120
src/Env.ts
|
|
@ -1,120 +0,0 @@
|
|||
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;
|
||||
47
src/Environment.ts
Normal file
47
src/Environment.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
339
src/HtmlAssistant.ts
Normal file
339
src/HtmlAssistant.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { ENV, Log } from "Environment";
|
||||
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).
|
||||
*/
|
||||
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.SVG_FAILED_ICON_BLOB);
|
||||
}
|
||||
|
||||
public static setInvalid(element: HTMLElement) {
|
||||
this.setCacheState(element, HTMLElementCacheState.INVALID);
|
||||
if (element instanceof HTMLImageElement)
|
||||
this.setIcon(element, this.SVG_FAILED_ICON_BLOB);
|
||||
}
|
||||
|
||||
/**
|
||||
* - 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.SVG_LOADING_ICON_BLOB);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo
|
||||
* @see {@link https://lucide.dev/icons/loader}
|
||||
*/
|
||||
private static readonly SVG_LOADING_ICON_BLOB = new Blob(
|
||||
[`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#919191" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`],
|
||||
{ type: "image/svg+xml" }
|
||||
);
|
||||
|
||||
private static readonly SVG_FAILED_ICON_BLOB = new Blob(
|
||||
[`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ff0000" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`],
|
||||
{ type: "image/svg+xml" }
|
||||
);
|
||||
}
|
||||
|
||||
class HtmlAssistantFileNotFoundError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`File not found: ${url}`);
|
||||
this.name = "HtmlAssistantFileNotFoundError";
|
||||
}
|
||||
}
|
||||
100
src/InfoModal.ts
Normal file
100
src/InfoModal.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { App, ButtonComponent, Modal, Setting } from 'obsidian';
|
||||
import { CacheManager } from 'CacheManager';
|
||||
import { PluginSettings } from 'Settings';
|
||||
import { Notice } from 'Environment';
|
||||
|
||||
export class InfoModal extends Modal {
|
||||
private cacheManager: CacheManager;
|
||||
private settings: PluginSettings;
|
||||
|
||||
constructor(app: App, cacheManager: CacheManager, settings: PluginSettings) {
|
||||
super(app);
|
||||
this.cacheManager = cacheManager;
|
||||
this.settings = settings;
|
||||
|
||||
this.setTitle('Cacheboard');
|
||||
|
||||
this.clearCacheSetting = new Setting(this.contentEl)
|
||||
.setName('Total number of files cached')
|
||||
.addButton((button) => {
|
||||
this.clearCacheButton = button;
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
|
||||
await 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");
|
||||
|
||||
button.buttonEl.remove();
|
||||
this.populate();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (settings.showDebugInfo) {
|
||||
this.debugInfoSetting = new Setting(this.contentEl)
|
||||
.setName('Debug info')
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Refresh metadata");
|
||||
button.onClick(async () => {
|
||||
await this.cacheManager.debug().loadMetadata();
|
||||
this.populate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((button) => {
|
||||
this.closeButton = button;
|
||||
button.buttonEl.tabIndex = 1;
|
||||
button.setButtonText("Close");
|
||||
button.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
debugInfoSetting?: Setting;
|
||||
clearCacheSetting: Setting;
|
||||
clearCacheButton: ButtonComponent;
|
||||
closeButton: ButtonComponent;
|
||||
|
||||
onOpen(): void {
|
||||
super.onOpen();
|
||||
|
||||
setTimeout(() => {
|
||||
this.clearCacheButton.buttonEl.tabIndex = 0
|
||||
this.closeButton.buttonEl.focus();
|
||||
});
|
||||
|
||||
this.populate();
|
||||
}
|
||||
|
||||
private populate() {
|
||||
setTimeout(() => {
|
||||
|
||||
this.debugInfoSetting?.descEl.empty();
|
||||
|
||||
this.cacheManager.info((info) => {
|
||||
this.clearCacheSetting.setDesc(`${info.numberOfFilesCached} file${info.numberOfFilesCached == 1 ? `` : `s`}`);
|
||||
|
||||
if (this.debugInfoSetting) {
|
||||
for (const line of info.summary.split("\n"))
|
||||
this.debugInfoSetting.descEl.createEl("div", {}, (p) => {
|
||||
p.innerText = line;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
159
src/ProcessingPass.ts
Normal file
159
src/ProcessingPass.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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}`}`);
|
||||
}
|
||||
|
||||
}
|
||||
135
src/Settings.ts
135
src/Settings.ts
|
|
@ -1,39 +1,33 @@
|
|||
import { CacheManager } from "cache/CacheManager";
|
||||
import { Env } from "Env";
|
||||
import { Plugin, PluginSettingTab, Setting } from "obsidian";
|
||||
import { Notice } from "ui/Notice";
|
||||
import { PluginSettingTab, Setting, Plugin, Platform } from "obsidian";
|
||||
import { ENV, Notice } from "Environment";
|
||||
import { CacheManager } from "CacheManager";
|
||||
|
||||
export interface PluginSettings {
|
||||
|
||||
/** Show a {@link Notice} when file download starts. */
|
||||
noticeOnDownload: boolean;
|
||||
/** Show a {@link Notice} when sync conflict files were detected and deleted. */
|
||||
noticeOnDeleteSyncConflictFile: boolean;
|
||||
noticeOnDownload: 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;
|
||||
}
|
||||
|
||||
type SettingsChanged = (settings: PluginSettings) => void;
|
||||
|
||||
export class SettingsManager {
|
||||
public settings: PluginSettings;
|
||||
public save: () => Promise<void>;
|
||||
public onChangedCallback: (name: string, value: unknown) => void | undefined;
|
||||
public onChangedCallback: (name: string, value: any) => void | undefined;
|
||||
|
||||
static readonly DEFAULT_SETTINGS: PluginSettings = {
|
||||
noticeOnDownload: true,
|
||||
noticeOnDeleteSyncConflictFile: false,
|
||||
omitNameInNotice: false,
|
||||
gitIgnoreCacheDir: true,
|
||||
showDebugInfo: false,
|
||||
|
|
@ -43,33 +37,16 @@ export class SettingsManager {
|
|||
gitIgnoreCacheDir: "gitIgnoreCacheDir",
|
||||
} as const;
|
||||
|
||||
constructor(settings: PluginSettings, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string, value: unknown) => void | undefined) {
|
||||
constructor(settings: any, save: (settings: PluginSettings) => Promise<void>, onChangedCallback: (name: string, value: any) => void | undefined) {
|
||||
this.settings = settings;
|
||||
this.save = () => save(this.settings);
|
||||
this.onChangedCallback = onChangedCallback;
|
||||
}
|
||||
|
||||
public onSettingsChangedExternally(settings: PluginSettings) {
|
||||
this.settings = settings;
|
||||
this.registeredChangedCallbacks.forEach(cb => cb(this.settings));
|
||||
}
|
||||
|
||||
public registerOnChangedCallback(evt: SettingsChanged) {
|
||||
if (!this.registeredChangedCallbacks.includes(evt))
|
||||
this.registeredChangedCallbacks.push(evt);
|
||||
}
|
||||
|
||||
public unregisterOnChangedCallback(evt: SettingsChanged) {
|
||||
this.registeredChangedCallbacks = this.registeredChangedCallbacks.filter(callback => callback !== evt);
|
||||
}
|
||||
|
||||
private registeredChangedCallbacks: SettingsChanged[] = [];
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -77,32 +54,13 @@ 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();
|
||||
|
||||
private async render() {
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
const settings = this.settingsManager.settings;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
let compactDownloadMessage: Setting | undefined = undefined;
|
||||
let compactDownloadMessage: Setting | undefined;
|
||||
const setCompactDownloadMessageVisibility = () => {
|
||||
if (settings.noticeOnDownload)
|
||||
compactDownloadMessage?.settingEl.show();
|
||||
|
|
@ -135,56 +93,59 @@ export class SettingTab extends PluginSettingTab {
|
|||
setCompactDownloadMessageVisibility();
|
||||
|
||||
const cachedFilesSetting = new Setting(containerEl).setName('Number of files cached');
|
||||
this.cacheManager.actualCachedFilePaths().then((filePaths) => {
|
||||
const num = filePaths.length;
|
||||
cachedFilesSetting.setDesc(`${num} file${num == 1 ? `` : `s`}.`);
|
||||
setTimeout(() => {
|
||||
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);
|
||||
Env.log.e("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);
|
||||
console.error("Error clearing cache.", error);
|
||||
}
|
||||
else {
|
||||
new Notice("Cache cleared");
|
||||
cachedFilesSetting.setDesc("Cache is empty.")
|
||||
|
||||
button.buttonEl.remove();
|
||||
button.buttonEl.remove();
|
||||
|
||||
Env.clearBrowserCache(() => new Notice('Electron Cache cleared successfully. Restart vault.'));
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}).catch(error => {
|
||||
Env.log.e("Failed to count cached files:", error);
|
||||
cachedFilesSetting.setDesc("Failed to count cached files.");
|
||||
|
||||
})
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Advanced").setHeading();
|
||||
|
||||
// This is more of an "info setting" assuring that a .gitignore file exists rather than allowing its removal.
|
||||
new Setting(containerEl)
|
||||
.setName("Exclude cache from Git")
|
||||
.setDesc(`Use a \`.gitignore\` file to prevent cached files from being visible to Git. Note that this option cannot be disabled here.`)
|
||||
.setClass("come-down-toggle-disabled")
|
||||
.addToggle((toggle) => {
|
||||
|
||||
const refreshDisabled = () => {
|
||||
if (settings.gitIgnoreCacheDir) {
|
||||
toggle.setDisabled(true);
|
||||
toggle.toggleEl.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +155,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);
|
||||
});
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
src/Url.ts
Normal file
98
src/Url.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
|
||||
|
||||
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://");
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1005
src/cache/CacheManager.ts
vendored
1005
src/cache/CacheManager.ts
vendored
File diff suppressed because it is too large
Load diff
562
src/main.ts
562
src/main.ts
|
|
@ -1,17 +1,12 @@
|
|||
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";
|
||||
|
||||
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";
|
||||
|
||||
interface PluginData {
|
||||
settings: PluginSettings;
|
||||
|
|
@ -22,16 +17,19 @@ 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;
|
||||
|
||||
override async onload() {
|
||||
Env.log.d("Plugin:onload");
|
||||
async onload() {
|
||||
|
||||
//#region Init
|
||||
|
||||
//const startTime = performance.now();
|
||||
|
||||
Notice.setName(this.manifest.name);
|
||||
|
||||
this.data = await ComeDownPlugin.loadPluginData(this);
|
||||
this.data = Object.assign({}, DEFAULT_DATA, await this.loadData());
|
||||
|
||||
await this.ensureCacheDir();
|
||||
await this.ensureGitIgnore();
|
||||
|
|
@ -48,13 +46,14 @@ export default class ComeDownPlugin extends Plugin {
|
|||
);
|
||||
this.addSettingTab(new SettingTab(this, this.settingsManager, this.cacheManager));
|
||||
|
||||
const viewPlugin = ViewPlugin.fromClass(EditorViewPlugin);
|
||||
this.registerEditorExtension([
|
||||
viewPlugin,
|
||||
EditorView.updateListener.of((update) => update.view.plugin(viewPlugin)?.postUpdate(update, this.editorViewUpdateListener.bind(this)))
|
||||
]);
|
||||
//console.log(`ComeDown: init: ${performance.now() - startTime} milliseconds`);
|
||||
|
||||
this.registerMarkdownPostProcessor((e, c) => this.markdownPostProcessor(e, c));
|
||||
//#endregion
|
||||
|
||||
//#region Register
|
||||
|
||||
this.registerMarkdownPostProcessor((e, c) => this.postProcessReadingModeHtml(e, c));
|
||||
this.registerEditorExtension(EditorView.updateListener.of((vu) => this.editorViewUpdateListener(vu)));
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
|
|
@ -73,69 +72,51 @@ export default class ComeDownPlugin extends Plugin {
|
|||
})
|
||||
);
|
||||
|
||||
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) {
|
||||
|
||||
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: "open-info-modal",
|
||||
name: "Open 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
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override async onunload() {
|
||||
Env.log.d("Plugin:onunload");
|
||||
await this.cacheManager.cancelAllOngoing();
|
||||
async onunload() {
|
||||
await this.cacheManager?.cancelAllOngoing();
|
||||
}
|
||||
|
||||
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 + "/" + PluginFile.Dynamic.CACHE.NAME + PluginFile.Dynamic.CACHE.EXT);
|
||||
this.cacheMetadataPath = normalizePath(`${pluginDir}/cache.json`);
|
||||
}
|
||||
else {
|
||||
const errorMsg = `Cannot load because plugin directory is unknown`;
|
||||
|
|
@ -145,22 +126,24 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
return this.cacheDirBacking;
|
||||
}
|
||||
private cacheDirBacking?: string = undefined;
|
||||
private gitIgnorePath: string = Env.str.EMPTY;
|
||||
private cacheMetadataPath: string = Env.str.EMPTY;
|
||||
private cacheDirBacking?: string;
|
||||
private gitIgnorePath: string;
|
||||
private cacheMetadataPath: string;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
|
@ -170,231 +153,136 @@ export default class ComeDownPlugin extends Plugin {
|
|||
await this.app.vault.adapter.remove(this.gitIgnorePath);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Processing
|
||||
|
||||
/**
|
||||
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)
|
||||
* 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
|
||||
*/
|
||||
private async removeSyncConflictFiles() {
|
||||
if (this.manifest.dir === undefined)
|
||||
return;
|
||||
private filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
|
||||
|
||||
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;
|
||||
};
|
||||
const state = HtmlAssistant.cacheState(imageElement)
|
||||
|
||||
try {
|
||||
// return HtmlAssistant.isCacheStateEqual(state, [
|
||||
// HTMLElementCacheState.ORIGINAL,
|
||||
// HTMLElementCacheState.ORIGINAL_SRC_REMOVED,
|
||||
// HTMLElementCacheState.CACHE_FAILED
|
||||
// ]);
|
||||
|
||||
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);
|
||||
}
|
||||
// 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, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
|
||||
Env.log.d("Plugin:editorViewUpdateListener");
|
||||
private editorViewUpdateListener(update: ViewUpdate) {
|
||||
|
||||
const l = ProcessingPass.createViewUpdateLogger();
|
||||
l.log(l.beginMsg("seq:", info.seqNum));
|
||||
const processingPass = ProcessingPass.beginFromViewUpdate(this.app, update, () => {
|
||||
// There is no file to work with. All that can be done is to cancel loading all external urls.
|
||||
const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, true, (imageElement) => {
|
||||
const src = imageElement.getAttribute(HTMLElementAttribute.SRC);
|
||||
return src !== null && Url.isValid(src) && Url.isExternal(src);
|
||||
});
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
});
|
||||
|
||||
const ctx: ProcessingContext = ProcessingContext.fromViewUpdate(this.app, l, update);
|
||||
ProcessingContext.logInit(ctx);
|
||||
if (processingPass) {
|
||||
|
||||
if (ProcessingPass.abortIfInvalidContext(ctx, plugin, info))
|
||||
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);
|
||||
|
||||
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
|
||||
return;
|
||||
// 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".
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 3. Start filtering: If the image element has a src, only keep external urls; if not, keep all.
|
||||
let isSrcOk = src !== null ? Url.isValid(src) && Url.isExternal(src) : true;
|
||||
|
||||
// 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);
|
||||
// 4. If the url was ok, then remove all states that have passed this stage already.
|
||||
return isSrcOk && this.filterIrrelevantCacheStates(imageElement);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
l.log(l.msg(l.t(() => `Found ${elementsToProcess.length} of ${elementsToProcess.length + remainingElements.length} images to process in current DOM.`)));
|
||||
|
||||
pass.handleRequestingAndSucceeded(remainingElements);
|
||||
pass.handleImagesNotInCurrentDOM([...elementsToProcess, ...remainingElements]);
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
await this.requestCache(elementsToProcess, pass);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* - 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) {
|
||||
|
||||
/**
|
||||
* @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");
|
||||
|
||||
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 { 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);
|
||||
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);
|
||||
});
|
||||
|
||||
if (imageElements.length > 0) {
|
||||
HtmlAssistant.cancelImageLoading(imageElements);
|
||||
this.requestCache(imageElements, processingPass);
|
||||
}
|
||||
else {
|
||||
processingPass.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async requestCache(imageElements: HTMLImageElement[], pass: ProcessingPass) {
|
||||
const l = pass.ctx.logr;
|
||||
/**
|
||||
*
|
||||
* @param imageElements
|
||||
* @param processingPass
|
||||
* @returns
|
||||
*/
|
||||
async requestCache(imageElements: HTMLImageElement[], processingPass: ProcessingPass) {
|
||||
|
||||
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED));
|
||||
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"));
|
||||
Log(`requestCache\n\tGot ${imageElements.length} <img> elements to populate.`);
|
||||
if (imageElements.length == 0)
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cacheManager.checkIfMetadataFileChangedExternally();
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, processingPass);
|
||||
|
||||
const requestGroups = groupRequests(imageElements, this.cacheManager, pass);
|
||||
|
||||
// First try to get src from local cache.
|
||||
// First try to get src from local cache.
|
||||
for (const requestGroup of requestGroups) {
|
||||
const existingCacheResult = await this.cacheManager.existingCache(requestGroup.request, true);
|
||||
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."}`}`)));
|
||||
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."}`}`);
|
||||
if (existingCacheResult.item)
|
||||
await handleRequestGroup(existingCacheResult.item, requestGroup);
|
||||
};
|
||||
|
|
@ -403,31 +291,26 @@ export default class ComeDownPlugin extends Plugin {
|
|||
let numberOfRemainingDownloads = remainingRequestGroups.length;
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
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.
|
||||
Log(`requestCache: All images were in cache. Done ${processingPass.passID}`);
|
||||
processingPass.end(this.cacheManager);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// If we are here, some images need to be downloaded.
|
||||
|
||||
// Show download notice
|
||||
if (this.settingsManager.settings.noticeOnDownload) {
|
||||
const notice = `↓ ${numberOfRemainingDownloads}`;
|
||||
new Notice(notice, undefined, this.settingsManager.settings.omitNameInNotice);
|
||||
processingPass.runInUpdatePass(() => {
|
||||
const notice = `↓ ${numberOfRemainingDownloads}`;
|
||||
new Notice(notice, undefined, this.settingsManager.settings.omitNameInNotice);
|
||||
});
|
||||
}
|
||||
|
||||
remainingRequestGroups.forEach((requestGroup) => {
|
||||
|
||||
for (const imageElement of requestGroup.imageElements) {
|
||||
HtmlAssistant.setLoading(imageElement);
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING_DOWNLOADING);
|
||||
HtmlAssistant.setLoadingIcon(imageElement);
|
||||
imageElement.setAttribute(HTMLElementAttribute.ALT, "Loading...");
|
||||
};
|
||||
|
||||
|
|
@ -440,32 +323,31 @@ export default class ComeDownPlugin extends Plugin {
|
|||
// Restore alt texts.
|
||||
imageElements.forEach((imageElement, index) => {
|
||||
const originalAlt = requestGroup.altAttributeValues[index];
|
||||
if (originalAlt && originalAlt.length > 0)
|
||||
if (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) {
|
||||
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`}`)));
|
||||
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`}`);
|
||||
if (result.fileExists === true)
|
||||
await handleRequestGroup(resultItem, requestGroup);
|
||||
await handleRequestGroup(result.item, requestGroup);
|
||||
else
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
imageElements.forEach((imageElement) => HtmlAssistant.setInvalid(imageElement));
|
||||
|
||||
if (Env.isDev && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
l.log(l.msg(`Plugin:requestCache: ${result.error.name}`), result.error);
|
||||
if (ENV.debugLog && (result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
console.error(`requestCache:\n\t${result.error.name}`, result.error);
|
||||
|
||||
if (!(result.error instanceof CacheFetchError || result.error instanceof CacheTypeError))
|
||||
Env.log.e("Plugin:requestCache:\n\tFailed to fetch cache", result.error);
|
||||
console.error("requestCache:\n\tFailed to fetch cache", result.error);
|
||||
}
|
||||
|
||||
if (numberOfRemainingDownloads == 0) {
|
||||
pass.end(this.cacheManager);
|
||||
processingPass.end(this.cacheManager);
|
||||
if (result.error instanceof CacheFetchError && result.error.isInternetDisconnected)
|
||||
new Notice("No internet connection.");
|
||||
}
|
||||
|
|
@ -473,21 +355,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.
|
||||
Env.log.e(errorResult.error);
|
||||
console.error(errorResult.error);
|
||||
//imageElements.forEach((imageElement) => HtmlAssistant.setFailed(imageElement));
|
||||
}
|
||||
else {
|
||||
|
|
@ -497,29 +379,29 @@ export default class ComeDownPlugin extends Plugin {
|
|||
requestGroup.cacheFileFound = errorResult ? false : true;
|
||||
|
||||
if (requestGroup.cacheFileFound) {
|
||||
l.log(l.t(() => l.msg(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)} 📦📦📦`)));
|
||||
pass.retainCache(requestGroup.request);
|
||||
Log(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)}, ID ${processingPass.passID} 📦📦📦`);
|
||||
processingPass.retainCacheFromRequest(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 pass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, pass: 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 processingPass
|
||||
* @returns
|
||||
*/
|
||||
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, processingPass: ProcessingPass) {
|
||||
|
||||
const groupedByRequest: Record<string, RequestGroup> = {};
|
||||
|
||||
imageElements.forEach((imageElement) => {
|
||||
HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.REQUESTING);
|
||||
|
||||
const src = HtmlAssistant.originalSrc(imageElement, true);
|
||||
const src = HtmlAssistant.imageElementOriginalSrc(imageElement);
|
||||
|
||||
if (src) {
|
||||
const key = CacheManager.createCacheKeyFromOriginalSrc(src);
|
||||
|
|
@ -531,10 +413,10 @@ export default class ComeDownPlugin extends Plugin {
|
|||
group.altAttributeValues.push(alt);
|
||||
}
|
||||
else {
|
||||
const request = pass.ctx.isCacheAccessReadWrite() ? CacheManager.createRequest(src, pass.ctx.associatedFile.path) : CacheManager.createReadOnlyRequest(src);
|
||||
const request: CacheRequest = { source: src, requesterPath: processingPass.associatedFile.path };
|
||||
const validationError = cacheManager.validateRequest(request);
|
||||
if (validationError) {
|
||||
Env.log.e(`Cache request failed validation.`, validationError)
|
||||
console.error(`Cache request failed validation.`, validationError)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
else {
|
||||
|
|
@ -548,7 +430,7 @@ export default class ComeDownPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
else {
|
||||
Env.log.e(`Image element does not have a source. Omitting.`)
|
||||
console.error(`Image element does not have a source. Omitting.`)
|
||||
HtmlAssistant.setInvalid(imageElement);
|
||||
}
|
||||
});
|
||||
|
|
@ -556,36 +438,22 @@ 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,361 +0,0 @@
|
|||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// types.ts - Global type declarations
|
||||
|
||||
// Global type aliases
|
||||
|
||||
export type Prettify<T> = {
|
||||
[K in keyof T]: T[K];
|
||||
} & {};
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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;
|
||||
private settings: PluginSettings;
|
||||
|
||||
constructor(app: App, cacheManager: CacheManager, settings: PluginSettings) {
|
||||
super(app);
|
||||
this.cacheManager = cacheManager;
|
||||
this.settings = settings;
|
||||
|
||||
this.setTitle('Cacheboard');
|
||||
|
||||
this.clearCacheSetting = new Setting(this.contentEl)
|
||||
.setName('Total number of files cached')
|
||||
.addButton((button) => {
|
||||
this.clearCacheButton = button;
|
||||
button.buttonEl.tabIndex = -1;
|
||||
button.setButtonText("Delete");
|
||||
button.onClick(() => {
|
||||
button.setButtonText("Confirm cache delete");
|
||||
button.setWarning();
|
||||
button.onClick(async () => {
|
||||
|
||||
await 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");
|
||||
|
||||
button.buttonEl.remove();
|
||||
this.populate();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (settings.showDebugInfo) {
|
||||
this.debugInfoSetting = new Setting(this.contentEl)
|
||||
.setName('Debug info')
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Refresh metadata");
|
||||
button.onClick(async () => {
|
||||
await this.cacheManager.debug().loadMetadata();
|
||||
this.populate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(this.contentEl)
|
||||
.addButton((button) => {
|
||||
this.closeButton = button;
|
||||
button.buttonEl.tabIndex = 1;
|
||||
button.setButtonText("Close");
|
||||
button.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
debugInfoSetting?: Setting;
|
||||
clearCacheSetting: Setting;
|
||||
clearCacheButton!: ButtonComponent;
|
||||
closeButton!: ButtonComponent;
|
||||
|
||||
override onOpen(): void {
|
||||
super.onOpen();
|
||||
this.cacheManager.registerMetadataChanged(this.onMetadataChangedCallback);
|
||||
setTimeout(() => {
|
||||
this.clearCacheButton.buttonEl.tabIndex = 0
|
||||
this.closeButton.buttonEl.focus();
|
||||
});
|
||||
|
||||
this.populate();
|
||||
}
|
||||
|
||||
override onClose(): void {
|
||||
super.onClose();
|
||||
this.cacheManager.unregisterMetadataChanged(this.onMetadataChangedCallback);
|
||||
}
|
||||
|
||||
private populate() {
|
||||
setTimeout(() => {
|
||||
|
||||
this.debugInfoSetting?.descEl.empty();
|
||||
|
||||
this.cacheManager.info((info) => {
|
||||
this.clearCacheSetting.setDesc(`${info.numberOfFilesCached} file${info.numberOfFilesCached == 1 ? `` : `s`}`);
|
||||
|
||||
if (this.debugInfoSetting) {
|
||||
for (const line of info.summary.split("\n"))
|
||||
this.debugInfoSetting.descEl.createEl("div", {}, (p) => {
|
||||
p.innerText = line;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private onMetadataChangedCallback = () => this.populate();
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
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
155
src/utils/Url.ts
|
|
@ -1,155 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
|
||||
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,6 +0,0 @@
|
|||
.come-down-toggle-disabled .checkbox-container.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.75;
|
||||
filter: grayscale(30%) contrast(90%);
|
||||
}
|
||||
|
||||
|
|
@ -1,47 +1,19 @@
|
|||
{
|
||||
"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"
|
||||
]
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,3 @@
|
|||
{
|
||||
"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",
|
||||
"1.0.1": "1.8.0",
|
||||
"1.0.0": "1.8.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue