Compare commits

..

No commits in common. "main" and "1.0.7" have entirely different histories.
main ... 1.0.7

33 changed files with 1348 additions and 4124 deletions

View file

@ -1,20 +1,10 @@
{
"tools": {
"autoAccept": false
},
"context": {
"fileName": [
"AGENTS.md"
],
"fileFiltering": {
"respectGitIgnore": true,
"enableRecursiveFileSearch": true
}
},
"ui": {
"hideBanner": true
},
"privacy": {
"usageStatisticsEnabled": false
}
"autoAccept": false,
"contextFileName": ["GEMINI.md"],
"fileFiltering": {
"respectGitIgnore": true,
"enableRecursiveFileSearch": true
},
"hideBanner": true,
"usageStatisticsEnabled": false
}

14
.gitignore vendored
View file

@ -13,14 +13,14 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Hot Reload plugin
.hotreload
# 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
## The compiled main.js file.
main.js
## Created by plugin.
cache.json
cache/

View file

@ -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
View file

@ -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

View file

@ -27,7 +27,7 @@ Having the cache located inside the plugins 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).

View file

@ -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: "es2022",
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) {

View file

@ -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
View 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/**",
],
},
];

View file

@ -1,7 +1,7 @@
{
"id": "come-down",
"name": "Come Down",
"version": "1.1.1",
"version": "1.0.7",
"minAppVersion": "1.8.0",
"description": "Maintains a cache of your notes embedded external images.",
"author": "mntno",

View file

@ -4,31 +4,29 @@
"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",
"@codemirror/language": "6.11.3",
"@codemirror/view": "^6.38.2",
"@eslint/js": "9.35.0",
"@lezer/common": "^1.2.3",
"@types/node": "24.5.2",
"@typescript-eslint/eslint-plugin": "8.44.0",
"@typescript-eslint/parser": "8.44.0",
"builtin-modules": "5.0.0",
"esbuild": "0.25.10",
"globals": "16.4.0",
"image-size": "^2.0.2",
"obsidian": "latest",
"tslib": "^2.8.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.1",
"tslib": "2.8.1",
"typescript": "5.9.2",
"typescript-eslint": "8.44.0",
"xxhash-wasm": "1.1.0"
},
"type": "module",

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,9 @@
import { CacheMetadata, CacheMetadataImage, CacheRetainer, CacheRoot, CacheType, EMPTY_CACHE_ROOT } from "cache/CacheMetadata";
import { Env } from "Env";
import { imageSize } from "image-size";
import { normalizePath, requestUrl, Vault } from "obsidian";
import { Logger } from "utils/Logger";
import { Url } from "utils/Url";
import { Err } from "utils/ts";
import xxhash, { XXHashAPI } from "xxhash-wasm";
import { CacheMetadata, CacheMetadataImage, CacheRetainer, CacheRoot, CacheType, EMPTY_CACHE_ROOT } from "./CacheMetadata";
import { Env } from "./Env";
import { Url } from "./utils/Url";
export interface CacheRequest {
@ -17,8 +15,6 @@ export interface CacheRequest {
/** @todo Can do without. However, this makes each {@link CacheRequest} unique per requester. */
requesterPath: string;
isReadOnly: boolean;
}
export interface CacheItem {
@ -84,7 +80,7 @@ export class CacheFetchError extends CacheError {
constructor(cacheKey: string, readonly error: Error | null, readonly info: { readonly sourceUrl: string, readonly statusCode?: number }) {
super(cacheKey, `Failed to fetch cache: ${cacheKey}. Status: ${info.statusCode ?? "Unknown"}`, { cause: error });
super(cacheKey, `Failed to fetch cache: ${cacheKey}. Status: ${info?.statusCode ?? "Unknown"}`, { cause: error });
if (error) {
if (error.message && error.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
@ -100,7 +96,7 @@ export class CacheFetchError extends CacheError {
}
}
else {
this.isRetryable = !this.info.statusCode || (this.info.statusCode >= 500 && this.info.statusCode < 600);
this.isRetryable = !this.info?.statusCode || (this.info?.statusCode >= 500 && this.info?.statusCode < 600);
}
}
}
@ -122,7 +118,7 @@ export type MetadataChanged = (data: CacheRoot) => void;
export class CacheManager {
private metadataRoot: CacheRoot = EMPTY_CACHE_ROOT;
private metadataRoot: CacheRoot;
private get cache(): Record<string, CacheMetadata> {
return this.metadataRoot.items;
@ -136,7 +132,6 @@ export class CacheManager {
private readonly metadataFilePath: string;
private static hasher: XXHashAPI;
private static isHasherInitialized = false;
private readonly filePathsToOmitWhenClearingCache: string[]
@ -149,10 +144,8 @@ export class CacheManager {
public static async create(vault: Vault, cacheDir: string, metadataFilePath: string, filePathsToOmitWhenClearingCache: string[] = []) {
const instance = new this(vault, cacheDir, metadataFilePath, filePathsToOmitWhenClearingCache);
if (!CacheManager.isHasherInitialized) {
CacheManager.hasher = await xxhash(); // Seems to be quick.
CacheManager.isHasherInitialized = true;
}
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.
@ -175,6 +168,8 @@ export class CacheManager {
try {
await this.loadMetadata();
this.cacheInitiated = true;
} catch (error) {
throw error;
} finally {
this.initPromise = null; // Reset when done.
}
@ -220,23 +215,18 @@ export class CacheManager {
requestsToIgnore?: Set<CacheRequest>;
/** If `true` will remove any cache references on the associated {@link CacheRetainer|retainer}. */
preventReleases?: boolean;
/** Caller's logger */
logger?: Logger;
}) {
const { requestsToIgnore, preventReleases = false, logger } = options || {};
const { requestsToIgnore, preventReleases = false } = options || {};
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "updateRetainedCaches");
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retainer: ", retainerPath);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, Env.dev.thunkedStr(() => `updateRetainedCaches: \n\t${requests.length} retain requests:\n\t\t${requests.map(r => r.source).join("\n\t\t")} \n\tRetainer: ${retainerPath}`));
Env.dev.runDev(() => {
requests.forEach(request => Env.assert(request.requesterPath === retainerPath, `Expected retainer of cache request (${request.source}) equal to ${retainerPath}`))
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `${requests.length} retain requests:`);
requests.forEach(request => Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t\t", request.source));
Env.log.cm(`\tTo ignore count: ${requestsToIgnore ? requestsToIgnore.size : 0}, preventCacheRelases: ${preventReleases}`);
Env.log.cm("\tCurrent retain count: ", this.retainCount());
});
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `To ignore count: ${requestsToIgnore ? requestsToIgnore.size : 0}, preventCacheRelases: ${preventReleases}`);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Current retain count: ", this.retainCount());
// If retainer/file does not exist / is not yet registered, it will be.
const retainer = this.metadataRoot.retainers[retainerPath];
let retainer = this.metadataRoot.retainers[retainerPath];
const cacheKeysCurrentlyReferenced = retainer ? [...retainer.ref] : [];
// Populate set of keys to retain.
@ -250,7 +240,7 @@ export class CacheManager {
cacheKeysRequestedReferenced.push(cacheKey);
}
else {
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\tCache key does not (yet) exist:", cacheKey);
Env.log.cm("\tCache key does not (yet) exist:", cacheKey);
}
}
@ -270,31 +260,24 @@ export class CacheManager {
.filter(key => !ignoredReferences.includes(key));
if (addedReferences.length == 0 && removedReferences.length == 0) {
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", `No change: all requested cache keys match the already referenced/retained keys. Aborting.`)
Env.log.cm(`\tNo change: all requested cache keys match the already referenced/retained keys. Aborting.`)
return;
}
// Input validation: Prevent removeal cache items that are not referenced by the retainer. This should not happen.
const invalidRemovedReferences = removedReferences.filter(key => !cacheKeysCurrentlyReferenced.includes(key));
const validRemovedReferences = removedReferences.filter(key => !invalidRemovedReferences.includes(key));
if (invalidRemovedReferences.length > 0)
Env.log.e(`Attempted to remove the following cache references from retainer '${retainerPath}' which does not reference them ${logger?.idString ?? Env.str.EMPTY}:`, invalidRemovedReferences);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Requested cache keys:", cacheKeysRequestedReferenced);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Retaining:", addedReferences);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Releasing:", validRemovedReferences);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Ignoring:", ignoredReferences);
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Not releasing (invalid):", invalidRemovedReferences);
Env.log.cm("\tRequested cache keys:", cacheKeysRequestedReferenced);
Env.log.cm("\tRetaining:", addedReferences);
Env.log.cm("\tReleasing:", removedReferences);
Env.log.cm("\tIgnoring:", ignoredReferences);
let newRef = retainer !== undefined ? [...retainer.ref, ...addedReferences] : addedReferences;
newRef = newRef.filter(r => !validRemovedReferences.includes(r));
newRef = newRef.filter(r => !removedReferences.includes(r));
this.setRetainerRefs(retainerPath, newRef);
const updatedRetainCount = this.retainCount();
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "\t", "Updated retain count: ", updatedRetainCount);
Env.log.cm("\tUpdated retain count: ", updatedRetainCount);
// Remove caches that are no longer referenced by any retainer.
for (const removedReference of validRemovedReferences) {
for (const removedReference of removedReferences) {
if (!this.isRetained(updatedRetainCount, removedReference))
await this.removeCacheItem(removedReference);
}
@ -341,7 +324,7 @@ export class CacheManager {
public renameRetainer(oldPath: string, path: string) {
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager.renameRetainer\n\t${oldPath}`);
const retainer = this.metadataRoot.retainers[oldPath];
let retainer = this.metadataRoot.retainers[oldPath];
if (retainer !== undefined) {
this.metadataRoot.retainers[path] = retainer;
delete this.metadataRoot.retainers[oldPath];
@ -355,7 +338,7 @@ export class CacheManager {
public async removeRetainer(path: string) {
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager.removeRetainer\n\t${path}`);
const retainer = this.metadataRoot.retainers[path];
let retainer = this.metadataRoot.retainers[path];
if (retainer === undefined) {
// This can happen if a file which hasn't been open was deleted without opening it.
Env.log.cm("\tFailed: No retainer found.");
@ -387,7 +370,7 @@ export class CacheManager {
}
/**
* If {@link cacheKey} is found in metadata, will remove the associated file from cache and from the metadata.
* If {@link cacheKey} is found in metadata, will removed the associated file from cache and from the metadata.
*
* {@link saveMetadataIfDirty} needs to be called at some point to persist the matadata.
*
@ -397,7 +380,7 @@ export class CacheManager {
const metadata = this.cache[cacheKey];
Env.assert(metadata !== undefined, `Attempted to remove cash item using a non-existing key.`);
if (metadata !== undefined) {
Env.log.cm(Env.dev.icon.CACHE_MANAGER, "removeCacheItem", "\n\tRemoving", this.nameOfCachedFileFromMetadata(metadata, cacheKey));
Env.log.cm(Env.dev.icon.CACHE_MANAGER, `removeCacheItem\n\tRemoving ${this.nameOfCachedFileFromMetadata(metadata, cacheKey)}`);
await this.vault.adapter.remove(this.filePathToCachedFileFromMetadata(metadata, cacheKey));
delete this.cache[cacheKey];
this.isMetadataDirty = true;
@ -431,7 +414,7 @@ export class CacheManager {
// Increase count for each reference found.
for (const cacheKey of retainer.ref) {
// Note: make sure to `if (count !== undefined)` rather than `if(count)` otherwise `count` will not be treated as a `number` and addition will fail.
const count = retainCounts[cacheKey];
let count = retainCounts[cacheKey];
Env.assert(count !== undefined, "Retainer is referencing a cache key that does not exist", cacheKey);
if (count !== undefined)
retainCounts[cacheKey] = count + 1;
@ -453,7 +436,7 @@ export class CacheManager {
* @returns `true` if the key is retained in {@link retainCounts}.
*/
private isRetained(retainCounts: Record<string, number>, key: string) {
const count = retainCounts[key];
let count = retainCounts[key];
return count === undefined || count === 0 ? false : true; // Since 1.0.6, retain counts of zero are actually not present in the dictionary anymore.
}
@ -498,7 +481,7 @@ export class CacheManager {
//
const retainers: CacheRetainer[] = Object.values(this.metadataRoot.retainers);
//const numberOfRetainers = retainers.length;
const numberOfRetainers = retainers.length;
// Here the Mardown file has been deleted but its still exists as a retainer.
const retainersWithoutActualFile = [];
@ -564,10 +547,6 @@ export class CacheManager {
return new CacheResult(request, cacheKey, null, new CacheNotFoundError(cacheKey), undefined);
}
/**
* @remarks `vault.adapter.getResourcePath` adds (what looks like) a timestamp parameter to the end of the {@link CacheItem.resourcePath},
* e.g., "…/dbc4b9faa6ffacd2.png?1759472349631".
*/
private createCacheItem(metadata: CacheMetadata, fromCache: boolean): CacheItem {
return {
resourcePath: this.vault.adapter.getResourcePath(this.filePathToCachedFileFromMetadata(metadata)),
@ -642,7 +621,7 @@ export class CacheManager {
result = await this.download(request, sourceFileInfo);
}
catch (error) {
result = new CacheResult(request, cacheKey ?? Env.str.EMPTY, null, Err.toError(error));
result = new CacheResult(request, cacheKey ?? "", null, error);
}
finally {
if (cacheKey)
@ -670,7 +649,7 @@ export class CacheManager {
callback?.();
} catch (error) {
callback?.(Err.toError(error));
callback?.(error);
}
}
@ -788,7 +767,7 @@ export class CacheManager {
* Aborts current download requests for the specified file.
* @todo
*/
async cancelOngoing(_filePath: string) {
async cancelOngoing(filePath: string) {
await sleep(100);
}
@ -812,17 +791,11 @@ export class CacheManager {
return CacheManager.hasher.h64Raw(bytes).toString(16).padStart(16, '0');
}
public static createRequest(src: string, path: string, isReadOnly: boolean = false): CacheRequest {
public static createRequest(src: string, path: string): CacheRequest {
const source = src.trim();
Env.assert(source.length > 0);
const requesterPath = path.trim();
Env.assert(isReadOnly || requesterPath.length > 0);
return { source, requesterPath, isReadOnly: isReadOnly } satisfies CacheRequest;
}
/** @todo Also see {@link CacheRequest#requesterPath}. */
public static createReadOnlyRequest(src: string): CacheRequest {
return CacheManager.createRequest(src, Env.str.EMPTY, true);
Env.assert(source.length > 0 && requesterPath.length > 0);
return { source, requesterPath } satisfies CacheRequest;
}
public static isRequestEqual(request: CacheRequest, otherRequest: CacheRequest) {
@ -891,15 +864,12 @@ export class CacheManager {
const response = await requestUrl({ url: sourceUrl, method: 'GET', throw: false });
const headers = Url.normalizeHeaders(response.headers);
const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType];
const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl];
const etag = Url.parseETag(headers[Url.RESPONSE_HEADER_LOWERCASE.etag]);
const lastModifiedHeader = headers[Url.RESPONSE_HEADER_LOWERCASE.lastModified];
const lastModified = lastModifiedHeader !== undefined ? new Date(lastModifiedHeader).toISOString() : undefined;
const contentType = headers[Url.RESPONSE_HEADER_LOWERCASE.contentType] ?? undefined;
const cacheControl = headers[Url.RESPONSE_HEADER_LOWERCASE.cacheControl] ?? undefined;
//Env.log.cm(Env.dev.icon.CACHE_MANAGER, `CacheManager:download: Got response:\n\tcacheID: ${cacheKey}\n\t${response.status}\n\tcontentType: ${contentType}`);
if (cacheControl === Url.CACHE_CONTROL_LOWERCASE.noStore)
if (cacheControl == Url.CACHE_CONTROL_LOWERCASE.noStore)
throw new CacheError(`Caching not allowed on ${sourceUrl}.`, cacheKey);
if (response.status >= 400)
@ -929,8 +899,6 @@ export class CacheManager {
d: nowDateString,
l: nowDateString,
cc: cacheControl,
et: etag !== null ? etag : undefined,
m: lastModified,
},
f: {
s: sourceUrl,
@ -970,7 +938,7 @@ export class CacheManager {
if (error instanceof CacheError)
result = new CacheResult(request, cacheKey, null, error);
else
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, Err.toError(error), { sourceUrl: sourceUrl }));
result = new CacheResult(request, cacheKey, null, new CacheFetchError(cacheKey, error, { sourceUrl: sourceUrl }));
}
return result;
@ -993,7 +961,7 @@ export class CacheManager {
return {
w: sizeCalcResult.width,
h: sizeCalcResult.height,
t: sizeCalcResult.type ?? Env.str.EMPTY,
t: sizeCalcResult?.type ?? "",
};
} catch (error) {
if (error instanceof TypeError)

View file

@ -75,32 +75,9 @@ 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;
}

View file

@ -22,14 +22,13 @@ export const DevContext = {
logCategory: {
CACHE_MANAGER: true,
DEBUGGING: true,
EDIT_UPDATE_PASS: true,
POST_PROCESS_PASS: true,
WORKAROUNDS: false,
} as const,
icon: {
DEBUG: "🐞",
NONE: "  ",
CACHE_MANAGER: "📦",
EDIT_UPDATE_PASS: "✏️",
POST_PROCESS_PASS: "📖",
@ -48,19 +47,31 @@ export const Env = {
log: {
noop: () => { },
/** To provide very granular, low-level, and highly detailed information. These logs are often too numerous to be helpful during general development but are invaluable when you're trying to diagnose a specific, complex bug. */
d: devLogger.debug,
/**
* - This is the most general-purpose logging method. It's a good default when a message doesn't neatly fit into the error, warn, or info categories, or when you're just doing quick, ad-hoc debugging.
* - When you use `console.log()`, you are typically saying: "Just output this general message." It's more of a catch-all, or often used for quick, ad-hoc debugging prints.
*/
l: devLogger.log,
/**
* - To provide high-level, general information about the application's flow or significant events. These are like "milestones" that give you an overview of what the application is doing.
* - This is an informational message about a significant event or the general flow of the application. It implies a higher level of importance or a more structured type of message than a generic `log`.
*/
i: devLogger.info,
w: console.warn,
/** To indicate a potential issue, a suboptimal practice, a deprecated feature being used, or a situation that might lead to an error later but isn't critical right now. It's a "heads up" or a "soft error." */
w: devLogger.warn,
/** Always logs */
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: {
@ -71,22 +82,18 @@ export const Env = {
},
},
/**
* @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");
app.commands.executeCommandById("app:reload");
}
else
appOrCallback();
})
.catch((error: unknown) => console.error('Error clearing cache:', error));
.catch((error: any) => console.error('Error clearing cache:', error));
}
},
@ -99,22 +106,11 @@ export const Env = {
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
} as const;
export type LoggerFn = (...args: unknown[]) => void;

View file

@ -1,7 +1,6 @@
import { Env } from "Env";
import { ObsAssistant } from "utils/ObsAssistant";
import { Err } from "utils/ts";
import { Url } from "utils/Url";
import { getIcon } from "obsidian";
import { Env } from "./Env";
import { Url } from "./utils/Url";
export const enum HTMLElementCacheState {
/** Untouched by the plugin. */
@ -147,9 +146,9 @@ export class HtmlAssistant {
HtmlAssistant.setIcon(imageElement, HtmlAssistant.loadingIcon);
}
public static setFailed(element: HTMLImageElement, readOnlyAccess = false) {
public static setFailed(element: HTMLImageElement) {
HtmlAssistant.setCacheState(element, HTMLElementCacheState.CACHE_FAILED);
HtmlAssistant.setIcon(element, readOnlyAccess ? HtmlAssistant.readOnlyIcon : HtmlAssistant.failedIcon);
HtmlAssistant.setIcon(element, HtmlAssistant.failedIcon);
}
public static setInvalid(element: HTMLImageElement) {
@ -159,7 +158,7 @@ export class HtmlAssistant {
private static setCanceled(element: HTMLImageElement) {
HtmlAssistant.setCacheState(element, HTMLElementCacheState.ORIGINAL_SRC_REMOVED);
HtmlAssistant.setIcon(element, HtmlAssistant.placeholderIcon);
HtmlAssistant.setIcon(element, HtmlAssistant.emptyIcon);
}
/**
@ -171,8 +170,8 @@ export class HtmlAssistant {
*
* @param imageElements
*/
public static cancelImageLoadIfNeeded(imageElements: HTMLImageElement[]) {
Env.log.d("HtmlAssistant:cancelImageLoading: Number of images to check:", imageElements.length);
public static cancelImageLoading(imageElements: HTMLImageElement[]) {
Env.log.d("HtmlAssistant:cancelImageLoading: Number of images:", imageElements.length);
if (imageElements.length === 0)
return;
@ -285,7 +284,7 @@ export class HtmlAssistant {
if (error instanceof TypeError)
return new HtmlAssistantFileNotFoundError(src);
else
return Err.toError(error);
return error;
}
if (response.ok) {
@ -297,7 +296,7 @@ export class HtmlAssistant {
else
return new Error(`Invalid blob URL: ${url}`);
} catch (error) {
return Err.toError(error);
return error;
}
}
else {
@ -320,7 +319,10 @@ export class HtmlAssistant {
*/
private static get loadingIcon(): Blob {
if (this.loadingIconBacking === undefined) {
const icon = ObsAssistant.getIcon("image-down", { fallbackIconID: "bug" }); // "loader"
const icon = getIcon("loader")
console.assert(icon, "loader icon id not found");
icon!.setAttribute("stroke", "#919191");
icon!.setAttribute("stroke-width", "1");
this.loadingIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
}
return this.loadingIconBacking;
@ -329,28 +331,22 @@ export class HtmlAssistant {
private static get failedIcon(): Blob {
if (this.failedIconBacking === undefined) {
const icon = ObsAssistant.getIcon("image", { color: "#ff0000", fallbackIconID: "bug" }); // image-off
const icon = getIcon("image")
console.assert(icon, "image icon id not found");
icon!.setAttribute("stroke", "#ff0000");
icon!.setAttribute("stroke-width", "1");
this.failedIconBacking = new Blob([icon!.outerHTML], { type: "image/svg+xml" });
}
return this.failedIconBacking;
}
private static failedIconBacking?: Blob;
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 get emptyIcon(): Blob {
if (this.emptyIconBacking === undefined)
this.emptyIconBacking = new Blob(['<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg"></svg>'], { type: "image/svg+xml" });
return this.emptyIconBacking;
}
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;
private static emptyIconBacking?: Blob;
}
class HtmlAssistantFileNotFoundError extends Error {

425
src/ProcessingPass.ts Normal file
View file

@ -0,0 +1,425 @@
import { ViewUpdate } from "@codemirror/view";
import { App, ItemView, MarkdownPostProcessorContext, MarkdownView, TFile, View } from "obsidian";
import { CacheManager, CacheRequest } from "./CacheManager";
import { Env, LoggerFn } from "./Env";
import { HtmlAssistant, HTMLElementCacheState } from "./HtmlAssistant";
import { CodeMirrorAssistant } from "./utils/CodeMirrorAssistant";
import { ObsAssistant, ObsViewMode } from "./utils/ObsAssistant";
import { Url } from "./utils/Url";
import { Workarounds } from "./Workarounds";
export class ProcessingPass {
private readonly view: View;
private readonly itemView?: ItemView;
private readonly markdownView?: MarkdownView;
public readonly associatedFile: TFile;
/** `true` when invoked by the Markdown post processor. */
public readonly isInPostProcessingPass: boolean;
public readonly mode: ObsViewMode;
private readonly viewUpdate?: ViewUpdate;
public readonly log: LoggerFn;
public readonly passID: number;
private static updatePassID: number = 0;
private static postProcessorPassID: number = 0;
private constructor(id: number, isInPostProcessingPass: boolean, view: View, file: TFile, log: LoggerFn, viewUpdate?: ViewUpdate) {
this.passID = id;
this.isInPostProcessingPass = isInPostProcessingPass;
this.view = view;
this.itemView = view instanceof ItemView ? view : undefined;
this.markdownView = view instanceof MarkdownView ? view : undefined;
this.associatedFile = file;
this.mode = ObsAssistant.viewMode(view);
this.viewUpdate = viewUpdate;
this.log = log;
}
public static beginFromViewUpdate(app: App, viewUpdate: ViewUpdate, options?: {
/** Return `null` is view is in source mode. Defaults to `true`. */
abortInSourceMode: boolean;
noFile?: (id: number, log: LoggerFn) => void;
}): ProcessingPass | null {
const thisID = this.updatePassID++;
const log = Env.log.edit;
const {
abortInSourceMode = true,
} = options || {};
const view = app.workspace.getActiveViewOfType(View);
const associatedFile = ObsAssistant.getFileFromView(view);
if (view === null || associatedFile === null) {
ProcessingPass.handleNoAssociatedFile(viewUpdate, thisID, log);
options?.noFile?.(thisID, log);
log(ProcessingPass.abortLogMsg(true, thisID));
return null;
}
const instance = new this(thisID, false, view, associatedFile, log, viewUpdate);
if (abortInSourceMode && instance.mode === "source") {
log(ProcessingPass.abortLogMsg(true, thisID));
return null;
}
instance.log(instance.logMsg(`Start edit update pass in ${instance.mode} mode. ➡️🚪`));
if (Env.isDev && instance.mode === "preview") {
const u: Record<string, boolean> = {
docChanged: viewUpdate.docChanged,
viewportChanged: viewUpdate.viewportChanged,
selectionSet: viewUpdate.selectionSet,
focusChanged: viewUpdate.focusChanged,
geometryChanged: viewUpdate.geometryChanged,
heightChanged: viewUpdate.heightChanged,
viewportMoved: viewUpdate.viewportMoved,
};
const changed = Object.keys(u).filter(key => u[key]);
const notChanged = Object.keys(u).filter(key => !u[key]);
instance.log(`\t${changed.length} view updates:`);
if (changed.length > 0)
instance.log(`\t🟢 ${changed.join(", ")}`);
if (changed.length > 0 && notChanged.length > 0)
instance.log(`\t🔴 ${notChanged.join(", ")}`);
}
return instance;
}
public static beginFromPostProcessorContext(app: App, context: MarkdownPostProcessorContext): ProcessingPass | null {
const thisID = this.postProcessorPassID++;
const log = Env.log.read;
log(Env.dev.icon.POST_PROCESS_PASS, "Start post processor pass ➡️🚪", ProcessingPass.idString(thisID));
const view = app.workspace.getActiveViewOfType(View);
Env.dev.runDev(() => {
log(Env.dev.icon.POST_PROCESS_PASS, "\tView type:", view !== null ? ObsAssistant.viewClassTypeAsSting(view) + " / " + view.getViewType() : "No view available");
});
let associatedFile = ObsAssistant.getFileFromView(view);
if (associatedFile === null)
associatedFile = app.vault.getFileByPath(context.sourcePath);
if (view !== null && associatedFile !== null) {
return new this(thisID, true, view, associatedFile, log);
}
else {
log(ProcessingPass.abortLogMsg(false, thisID));
return null;
}
}
public end(cacheManager: CacheManager) {
this.log(this.logMsg(this.isInUpdatePass ? "Finished update pass." : "Finished post processing pass."), "✅🚪➡️");
const options = {
preventReleases: this.isInPostProcessingPass,
requestsToIgnore: this.requestsToIgnore,
};
cacheManager.updateRetainedCaches(Object.values(this.requestsToRetain), this.associatedFile.path, options).then(() => {
this.log(this.logMsg("\tCalling saveMetadataIfDirty"));
cacheManager.saveMetadataIfDirty();
});
}
public enqueue(operation: () => Promise<void>): Promise<void> {
ProcessingPass.serialQueue = ProcessingPass.serialQueue.then(() => operation());
return ProcessingPass.serialQueue;
}
private static serialQueue: Promise<void> = Promise.resolve();
public get idString() {
return "ID " + this.passID;
}
public static idString(id: number) {
return "ID " + id
}
public abortLogMsg() {
return ProcessingPass.abortLogMsg(this.isInUpdatePass, this.passID);
}
public static abortLogMsg(isInUpdatePass: boolean, passID: number) {
if (!Env.isDev)
return Env.str.EMPTY;
if (isInUpdatePass)
return `${Env.dev.icon.EDIT_UPDATE_PASS} Aborting update pass ❌🚪➡️ ${ProcessingPass.idString(passID)}`;
else
return `${Env.dev.icon.POST_PROCESS_PASS} Aborting post processing pass ❌🚪➡️ ${ProcessingPass.idString(passID)}`;
}
public logMsg(...msg: string[]) {
if (!Env.isDev)
return Env.str.EMPTY;
return `${this.isInUpdatePass ? Env.dev.icon.EDIT_UPDATE_PASS : Env.dev.icon.POST_PROCESS_PASS} ${msg.join(Env.str.SPACE)} ${this.idString}`;
}
public get isInUpdatePass() {
return !this.isInPostProcessingPass;
}
/**
* Only available if the {@link View} is an {@link ItemView}. If traversing descendants, {@link containerEl} can also be used.
* @returns `<div class="view-content">` which is a descendant of {@link containerEl} and the parent of both the reader and source container elements.
*/
public get contentEl(): HTMLElement | null {
return this.itemView?.contentEl ?? null;
}
/** @returns The element `<div class="workspace-leaf-content" data-type="markdown" data-mode="X">` where `X` is `preview` if in reader view or `source` otherwise. {@link contentEl} is a descendant. */
public get containerEl(): HTMLElement {
return this.view.containerEl;
}
public retainCache(urlOrCache: string | CacheRequest) {
Env.assert(urlOrCache);
Env.log.d(Env.dev.thunkedStr(() => this.logMsg(`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.associatedFile.path);
}
else {
this.requestsToRetain[urlOrCache.source] = urlOrCache;
}
}
private requestsToRetain: Record<string, CacheRequest> = {};
public ignoreCache(src: string) {
Env.assert(src);
this.log(Env.dev.thunkedStr(() => this.logMsg(`ignoreCache: Will ignore ${CacheManager.createCacheKeyFromOriginalSrc(src)} at the end of pass`)));
this.requestsToIgnore.add(CacheManager.createRequest(src, this.associatedFile.path));
}
private requestsToIgnore = new Set<CacheRequest>();
public get currentNumberOfRequestsToRetain() {
return Object.keys(this.requestsToRetain).length;
}
/** No file/note info is available yet but the contentDOM is so cancel image loading. */
public static handleNoAssociatedFile(update: ViewUpdate, id: number, log: LoggerFn) {
log(Env.dev.icon.EDIT_UPDATE_PASS, "No associated file in this update. Will cancel all `img` tags found in current DOM.", ProcessingPass.idString(id));
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update);
// There is no file to work with. All that can be done is to cancel loading.
const imageElements = HtmlAssistant.findAllImageElements(update.view.contentDOM, true, (imageElement) => {
const src = HtmlAssistant.getSrc(imageElement);
// Filter out image elements without a src or invalid.
if (src === null || !Workarounds.handleInvalidImageElements(sourcesToIgnore, imageElement, src))
return false;
// Allow image elements with external urls through so they can be cancelled.
return HtmlAssistant.isImageToProcess(imageElement);
});
HtmlAssistant.cancelImageLoading(imageElements);
}
/**
* `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.assert(this.viewUpdate);
if (!this.viewUpdate)
return;
const imgSrcInDom = imageElementsInDom.map(el => {
Env.dev.assert(HtmlAssistant.cacheState(el) !== HTMLElementCacheState.ORIGINAL);
return HtmlAssistant.originalSrc(el, false);
});
const imagesOutsideViewport = CodeMirrorAssistant.findAllImages(this.viewUpdate.view, (url) => {
const normalizedUrl = Url.normalizeUrl(url);
if (imgSrcInDom.includes(normalizedUrl))
return false;
return Url.isValidExternalUrl(normalizedUrl); // These urls might be local.
});
this.log(Env.dev.thunkedStr(() => this.logMsg("Found " + imagesOutsideViewport?.length + " images outside viewport:" + imagesOutsideViewport?.map(f => f.src).join(", "))));
imagesOutsideViewport?.forEach(image => this.retainCache(image.src));
}
public static findRelevantImagesToProcessInPostProcessor(element: HTMLElement, _context: MarkdownPostProcessorContext, requireSrcAttribute: boolean): [imageElementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[]] {
const imageElements = HtmlAssistant.findAllImageElements(element, requireSrcAttribute);
const remainingElements: HTMLImageElement[] = [];
const elementsToProcess: HTMLImageElement[] = [];
const imagesToCancelFilter = (imageElement: HTMLImageElement) => {
return Logic.filterIrrelevantCacheStates(imageElement) || HtmlAssistant.isImageToProcess(imageElement);
};
for (const imageElement of imageElements) {
if (imagesToCancelFilter(imageElement))
elementsToProcess.push(imageElement);
else
remainingElements.push(imageElement);
}
return [elementsToProcess, remainingElements];
}
public findRelevantImagesToProcessViewUpdate(update: ViewUpdate): [imageElementsToProcess: HTMLImageElement[], remainingElements: HTMLImageElement[]] {
// 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(update.view.contentDOM, false);
const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update);
const remainingElements: HTMLImageElement[] = [];
const imagesToCancel: HTMLImageElement[] = [];
const imagesToCancelFilter = (imageElement: HTMLImageElement) => {
const src = HtmlAssistant.getSrc(imageElement);
// 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 Logic.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 Logic.filterIrrelevantCacheStates(imageElement);
};
for (const imageElement of imageElements) {
// 1. First check if a new URL was set on an existing element so that it will be canceled.
this.checkIfUrlChanged(update, imageElement);
if (imagesToCancelFilter(imageElement))
imagesToCancel.push(imageElement);
else
remainingElements.push(imageElement);
}
return [imagesToCancel, remainingElements];
}
/**
* 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.
*
* @param update
* @param imageElement
*/
private checkIfUrlChanged(update: ViewUpdate, imageElement: HTMLImageElement) {
Env.log.d(this.logMsg("ProcessingPass:resetIfNeeded"), update.docChanged, HtmlAssistant.cacheState(imageElement), HtmlAssistant.getSrc(imageElement), Url.isValidExternalUrl(HtmlAssistant.getSrc(imageElement)));
// 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 (update.docChanged && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL && Url.isValidExternalUrl(HtmlAssistant.getSrc(imageElement))) {
this.log(Env.dev.thunkedStr(() => this.logMsg(`Url changed on image from ${HtmlAssistant.originalSrc(imageElement)} to ${HtmlAssistant.getSrc(imageElement)}`)));
HtmlAssistant.resetElement(imageElement); // Set state to "untouched".
}
}
/** 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:handleActive:", 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 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.
*/
public static waitForElementAttachment(element: HTMLElement, timeoutMs = 500, delay = 1): Promise<void> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const check = () => {
if (element.parentElement)
resolve();
else if (Date.now() - startTime > timeoutMs)
reject(new Error(`Element was not attached to the DOM within ${timeoutMs}ms.`));
else
setTimeout(check, delay);
};
check();
});
}
}
class Logic {
/**
* 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 `false` if state of {@link imageElement} is one of the above mentioned irrelevant states.
*/
public static filterIrrelevantCacheStates(imageElement: HTMLImageElement) {
Env.log.d(Env.dev.icon.NONE, "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
);
}
}

View file

@ -1,7 +1,7 @@
import { CacheManager } from "cache/CacheManager";
import { Env } from "Env";
import { Plugin, PluginSettingTab, Setting } from "obsidian";
import { Notice } from "ui/Notice";
import { Platform, Plugin, PluginSettingTab, Setting } from "obsidian";
import { CacheManager } from "./CacheManager";
import { Env } from "./Env";
import { Notice } from "./ui/Notice";
export interface PluginSettings {
@ -29,7 +29,7 @@ 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,
@ -43,7 +43,7 @@ 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;
@ -87,7 +87,7 @@ export class SettingTab extends PluginSettingTab {
this.cacheManager.checkIfMetadataFileChangedExternally().then(() => this.render());
}
override hide(): void {
public hide(): void {
Env.log.d("SettingTab:hide");
if (this.isShown)
this.settingsManager.unregisterOnChangedCallback(this.onChangedCallback);
@ -102,7 +102,7 @@ export class SettingTab extends PluginSettingTab {
containerEl.empty();
let compactDownloadMessage: Setting | undefined = undefined;
let compactDownloadMessage: Setting | undefined;
const setCompactDownloadMessageVisibility = () => {
if (settings.noticeOnDownload)
compactDownloadMessage?.settingEl.show();
@ -161,7 +161,11 @@ export class SettingTab extends PluginSettingTab {
button.buttonEl.remove();
Env.clearBrowserCache(() => new Notice('Electron Cache cleared successfully. Restart vault.'));
if (Env.isDev && Platform.isDesktopApp) {
require('electron').remote.session.defaultSession.clearCache()
.then(() => new Notice('Electron Cache cleared successfully. Restart vault.'))
.catch((error: any) => Env.log.e('Error clearing cache:', error));
}
}
});
});
@ -194,7 +198,7 @@ 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);
});
});
}

View file

@ -1,7 +1,7 @@
import { ViewUpdate } from "@codemirror/view";
import { Env } from "Env";
import { HtmlAssistant, HTMLElementCacheState } from "processing/HtmlAssistant";
import { Url } from "utils/Url";
import { Env } from "./Env";
import { HtmlAssistant, HTMLElementAttribute, HTMLElementCacheState } from "./HtmlAssistant";
import { Url } from "./utils/Url";
export class Workarounds {
/**
@ -29,7 +29,7 @@ export class Workarounds {
if (update.changes.empty)
return null;
const sourcesToIgnore: string[] = [];
var sourcesToIgnore: string[] = [];
update.changes.iterChanges((_fromA, _toA, cursorStartPos, cursorEndPosition, inserted) => {
@ -83,7 +83,7 @@ export class Workarounds {
* 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 = /(?<!!)\[.*?\]\((.+?)\)/;
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.

View file

@ -1,16 +1,14 @@
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "cache/CacheManager";
import { Env } from "Env";
import { EditorView, ViewUpdate } from "@codemirror/view";
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 { CacheFetchError, CacheItem, CacheManager, CacheRequest, CacheTypeError } from "./CacheManager";
import { Env } from "./Env";
import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "./HtmlAssistant";
import { ProcessingPass } from "./ProcessingPass";
import { PluginSettings, SettingTab, SettingsManager } from "./Settings";
import { InfoModal } from "./ui/InfoModal";
import { Notice } from "./ui/Notice";
import { File } from "./utils/File";
import { ObsAssistant } from "./utils/ObsAssistant";
interface PluginData {
@ -22,11 +20,11 @@ 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() {
async onload() {
Env.log.d("Plugin:onload");
Notice.setName(this.manifest.name);
@ -48,12 +46,7 @@ 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)))
]);
this.registerEditorExtension(EditorView.updateListener.of((vu) => this.editorViewUpdateListener(vu)));
this.registerMarkdownPostProcessor((e, c) => this.markdownPostProcessor(e, c));
this.registerEvent(
@ -104,19 +97,20 @@ export default class ComeDownPlugin extends Plugin {
});
}
override async onunload() {
async onunload() {
Env.log.d("Plugin:onunload");
await this.cacheManager.cancelAllOngoing();
await this.cacheManager?.cancelAllOngoing();
}
override onExternalSettingsChange() {
public 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);
})
if (this.data) {
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> {
@ -145,9 +139,9 @@ 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.
@ -222,171 +216,105 @@ export default class ComeDownPlugin extends Plugin {
}
}
private editorViewUpdateListener(update: ViewUpdate, plugin: EditorViewPlugin, info: EditorViewPluginInfo) {
private editorViewUpdateListener(update: ViewUpdate) {
Env.log.d("Plugin:editorViewUpdateListener");
const l = ProcessingPass.createViewUpdateLogger();
l.log(l.beginMsg("seq:", info.seqNum));
const ctx: ProcessingContext = ProcessingContext.fromViewUpdate(this.app, l, update);
ProcessingContext.logInit(ctx);
if (ProcessingPass.abortIfInvalidContext(ctx, plugin, info))
const pass = ProcessingPass.beginFromViewUpdate(this.app, update, { abortInSourceMode: true });
if (!pass)
return;
if (ProcessingPass.abortIfMode(ctx, "source")) // No need to cancel anything in source mode.
return;
// No need to cancel anything in source mode. In reader mode, just cancel then abort. Leave it to the markdown post processor.
const [imagesToCancel] = pass.findRelevantImagesToProcessViewUpdate(update);
HtmlAssistant.cancelImageLoading(imagesToCancel);
// Find elements that needs to be cancelled, or if they already are cancelled, they need to be processed further, e.g. requesting cache.
const { elementsToProcess } = ProcessingPass.findRelevantImagesToProcessViewUpdate(ctx);
HtmlAssistant.cancelImageLoadIfNeeded(elementsToProcess);
if (ProcessingPass.abortIfMode(ctx, "reader")) // If reader mode, just cancel then abort. Post processor will handle it.
return;
// There are a few cases where a view update is called even though there are no actual updates. For example when switching a file in Obsidian.
ctx.assertViewUpdateContext();
if (!ctx.vuCtx.hasUpdates) {
l.log(l.abortMsg("no updates"));
if (pass.mode === "reader" || ComeDownPlugin.canAbortViewUpdate(imagesToCancel, update)) {
pass.log(pass.abortLogMsg());
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;
pass.enqueue(async () => {
// Read again to get the latest states (e.g. downloading).
const {
elementsToProcess,
remainingElements
} = ProcessingPass.findRelevantImagesToProcessViewUpdate(pass.ctx);
const [imagesToProcess, remainingElements] = pass.findRelevantImagesToProcessViewUpdate(update);
pass.log(Env.dev.icon.EDIT_UPDATE_PASS, Env.dev.thunkedStr(() => `Found ${imagesToProcess.length} of ${imagesToProcess.length + remainingElements.length} images to process in current DOM. Running in serial queue. ${pass.idString}`));
// 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 (ComeDownPlugin.canAbortViewUpdate(imagesToProcess, update)) {
pass.log(pass.abortLogMsg());
}
else {
await this.requestCache(elementsToProcess, pass);
pass.handleRequestingAndSucceeded(remainingElements);
pass.handleImagesNotInCurrentDOM([...imagesToProcess, ...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 (imagesToProcess.length === 0 && update.docChanged) {
pass.log(pass.logMsg("Checking removals (no images to process but document changed)."));
pass.end(this.cacheManager);
}
else {
await this.requestCache(imagesToProcess, pass);
}
}
});
}
/**
* 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;
/** Refactored out of {@link editorViewUpdateListener} as a safty and self-documenting measure. See where `update.docChanged` in {@link editorViewUpdateListener}. */
private static canAbortViewUpdate = (elements: HTMLImageElement[], update: ViewUpdate) => elements.length === 0 && !update.docChanged;
/**
* @param element A chunk of HTML converted from markdown not yet attached to the DOM.
* @param context
*/
private markdownPostProcessor(element: HTMLElement, procContext: MarkdownPostProcessorContext) {
private markdownPostProcessor(element: HTMLElement, context: MarkdownPostProcessorContext) {
Env.log.d("Plugin:markdownPostProcessor");
const l = ProcessingPass.createPostProcessorLogger();
l.log(l.beginMsg());
const [imagesToProcess, remainingElements] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, context, true);
HtmlAssistant.cancelImageLoading(imagesToProcess);
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.
const pass = ProcessingPass.beginFromPostProcessorContext(this.app, context);
if (pass === null)
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));
if (imagesToProcess.length === 0) {
pass.log(pass.abortLogMsg());
return;
}
// Wait for each pass to set states before next pass is allowed.
queueAsyncMicrotask(async () => {
l.log(l.msg("Running in serial queue. 🚶🏼🚶🏼🚶🏼"));
pass.enqueue(async () => {
pass.log(pass.logMsg("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"))
const readerContainerEl = ObsAssistant.readerContainerEl(pass.contentEl ?? pass.containerEl);
Env.assert(readerContainerEl);
if (readerContainerEl === null) {
pass.log(pass.abortLogMsg());
return;
}
const containerEl = pass.ctx.getPreferredContainerEl();
// Wait a bit for all (or some more) elements to be attached to the DOM so that requests can be grouped. This is solely to reduce the number of download notices; it does not affect functionality.
await ProcessingPass.sleep(5);
// Read again to get the latest states (e.g. downloading).
const {
elementsToProcess: elementsToProcessInDom,
remainingElements
} = ProcessingPass.findRelevantImagesToProcessInPostProcessor(containerEl, false);
const [imagesToProcessInDom, remainingElementsInDom] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(readerContainerEl, context, 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.
// The element/chunk that was provided in this post processing callback is not within the viewport (+margin), but it needs to be handled now anyway because this callback is only called once for each chunk.
let imagesToProcessInCurrentElement: HTMLImageElement[] = [];
if (!pass.ctx.ppCtx.isElementAttached(containerEl))
imagesToProcessInCurrentElement = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, false).elementsToProcess;
if (element.parentElement === null)
[imagesToProcessInCurrentElement] = ProcessingPass.findRelevantImagesToProcessInPostProcessor(element, context, false);
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);
pass.log(pass.logMsg(Env.dev.thunkedStr(() => `Found ${imagesToProcessInDom.length} of ${imagesToProcessInDom.length + remainingElementsInDom.length} images in DOM and ${imagesToProcessInCurrentElement.length} images in current HTML chunk to process.`)));
await this.requestCache([...imagesToProcessInDom, ...imagesToProcessInCurrentElement], pass);
});
}
async requestCache(imageElements: HTMLImageElement[], pass: ProcessingPass) {
const l = pass.ctx.logr;
imageElements = imageElements.filter((imageElement) => HtmlAssistant.isElementCacheStateEqual(imageElement, HTMLElementCacheState.ORIGINAL_SRC_REMOVED, HTMLElementCacheState.CACHE_FAILED));
l.log(l.t(() => l.msg("Plugin:requestCache", "\n\t", `Got ${imageElements.length} <img> elements to populate.`)));
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache\n\tGot ${imageElements.length} <img> elements to populate.`)));
if (imageElements.length == 0) {
l.log(l.abortMsg("nothing to do"));
pass.log(pass.abortLogMsg());
return;
}
await this.cacheManager.checkIfMetadataFileChangedExternally();
const requestGroups = groupRequests(imageElements, this.cacheManager, pass);
@ -394,7 +322,7 @@ export default class ComeDownPlugin extends Plugin {
// 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."}`}`)));
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`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,20 +331,12 @@ 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.
pass.log(pass.logMsg("requestCache: All images were in cache. Done."));
pass.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) {
@ -448,7 +368,7 @@ export default class ComeDownPlugin extends Plugin {
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`}`)));
pass.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache:\n\tSettings src on ${imageElements.length} images\n\t${resultItem.metadata.f.n}\n\t${pass.isInPostProcessingPass ? `In HTML post processor` : `In edit listener`}`)));
if (result.fileExists === true)
await handleRequestGroup(resultItem, requestGroup);
else
@ -458,10 +378,10 @@ export default class ComeDownPlugin extends Plugin {
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);
pass.log(pass.logMsg(`requestCache: ${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);
Env.log.e("requestCache:\n\tFailed to fetch cache", result.error);
}
if (numberOfRemainingDownloads == 0) {
@ -497,7 +417,7 @@ 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.log(Env.dev.thunkedStr(() => pass.logMsg(`requestCache:handleRequestGroup: Found cached image: ${CacheManager.createCacheKeyFromMetadata(cacheItem.metadata)}, ID ${pass.passID} 📦📦📦`)));
pass.retainCache(requestGroup.request);
}
}
@ -509,10 +429,10 @@ export default class ComeDownPlugin extends Plugin {
* - Retain info that could be overwritten while making the request to be able to restore it afterwards.
*
* @param imageElements
* @param pass
* @param processingPass
* @returns
*/
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, pass: ProcessingPass) {
function groupRequests(imageElements: HTMLImageElement[], cacheManager: CacheManager, processingPass: ProcessingPass) {
const groupedByRequest: Record<string, RequestGroup> = {};
@ -531,7 +451,7 @@ 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 = CacheManager.createRequest(src, processingPass.associatedFile.path);
const validationError = cacheManager.validateRequest(request);
if (validationError) {
Env.log.e(`Cache request failed validation.`, validationError)

View file

@ -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;
}

View file

@ -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}`;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -1,7 +0,0 @@
// types.ts - Global type declarations
// Global type aliases
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

View file

@ -1,8 +1,8 @@
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";
import { CacheManager } from "../CacheManager";
import { PluginSettings } from "../Settings";
import { Notice } from "./Notice";
import { Env } from "../Env";
export class InfoModal extends Modal {
private cacheManager: CacheManager;
@ -67,10 +67,10 @@ export class InfoModal extends Modal {
debugInfoSetting?: Setting;
clearCacheSetting: Setting;
clearCacheButton!: ButtonComponent;
closeButton!: ButtonComponent;
clearCacheButton: ButtonComponent;
closeButton: ButtonComponent;
override onOpen(): void {
onOpen(): void {
super.onOpen();
this.cacheManager.registerMetadataChanged(this.onMetadataChangedCallback);
setTimeout(() => {
@ -81,7 +81,7 @@ export class InfoModal extends Modal {
this.populate();
}
override onClose(): void {
onClose(): void {
super.onClose();
this.cacheManager.unregisterMetadataChanged(this.onMetadataChangedCallback);
}

View file

@ -1,7 +1,7 @@
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { Tree } from "@lezer/common";
import { Env } from "Env";
import { EditorView } from "@codemirror/view";
import { SyntaxNodeRef, Tree } from "@lezer/common";
import { Env } from "../Env";
export interface ParsedImage {
src: string;
@ -14,10 +14,6 @@ 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`.
@ -83,86 +79,85 @@ export class CodeMirrorAssistant {
tree.iterate({
from,
to,
enter: (nodeRef) => {
const node = nodeRef.node;
enter: (node: SyntaxNodeRef) => {
if (node.name.includes(NodeName.CODEBLOCK_NODE_PART) || node.name.startsWith(NodeName.FRONTMATTER_DEF)) {
// Frontmatter and code blocks cannot contain (rendered) images.
if (node.name.includes(NodeName.CODEBLOCK_NODE_PART) || node.name === 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;
// Handle markdown images via tree structure (fast path)
if (node.name === NodeName.URL) {
const urlNode = node.node;
let urlNode = null;
let altNode = null;
let closingParenNode = null;
const closingParen = urlNode.nextSibling;
if (closingParen?.name !== NodeName.URL_FORMATTING)
return;
let currentNode = node.nextSibling;
while (currentNode) {
if (currentNode.name.startsWith(NodeName.IMAGE_MARKER)) break;
// Extract and filter URL as soon as possible
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; // Extract just the URL part, excluding possible appended titles and dimensions, e.g.,: `https://example.com/image3.gif "This is a title"`, `https://example.com/image4.jpg =300x200`, `https://example.com/image7.webp "Title text" =250x150`.
if (url === undefined)
return;
const isUrlNode = currentNode.name.endsWith(NodeName.URL) && !currentNode.name.includes(NodeName.FORMATTING);
const isAltNode = currentNode.name.startsWith(NodeName.IMAGE_ALT_LINK);
if (filter && !filter(url))
return;
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;
// Pattern 1: ![alt](url)
const p1 = urlNode.prevSibling; // (
const p2 = p1?.prevSibling; // ]
const p3 = p2?.prevSibling; // alt
const p4 = p3?.prevSibling; // [
const p5 = p4?.prevSibling; // !
if (p1?.name === NodeName.URL_FORMATTING &&
p2?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
p3?.name === NodeName.IMAGE_ALT_LINK &&
p4?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
p5?.name === NodeName.IMAGE_MARKER) {
const alt = view.state.doc.sliceString(p3.from, p3.to);
images.push({ src: url, alt, from: p5.from, to: closingParen.to });
return;
}
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 });
// Pattern 2: ![](url)
const s1 = urlNode.prevSibling; // ](
const s2 = s1?.prevSibling; // [
const s3 = s2?.prevSibling; // !
if (s1?.name === NodeName.URL_FORMATTING &&
s2?.name === NodeName.IMAGE_ALT_LINK_FORMATTING &&
s3?.name === NodeName.IMAGE_MARKER) {
images.push({ src: url, alt: undefined, from: s3.from, to: closingParen.to });
return;
}
}
// --- HTML Image Logic ---
if (node.name.startsWith(NodeName.HTML_BEGIN_TAG)) {
if (images.some(img => node.from >= img.from && node.to <= img.to)) return;
// Handle HTML img tags via targeted text parsing
if (node.name === NodeName.HTML_BEGIN_TAG) {
// Find the complete HTML tag by looking for the closing tag
let current = node.node;
let endNode = current.nextSibling;
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;
}
// Navigate to find the closing bracket
while (endNode && endNode.name !== NodeName.HTML_END_TAG)
endNode = endNode.nextSibling;
if (endNode) {
const text = view.state.doc.sliceString(node.from, endNode.to);
if (text.includes("<img")) {
const srcMatch = text.match(RegEx.HTML_SRC);
const htmlText = view.state.doc.sliceString(current.from, endNode.to);
// Only process img tags
if (htmlText.includes('<img')) {
const srcMatch = htmlText.match(RegEx.HTML_SRC);
if (srcMatch && srcMatch[1]) {
const url = srcMatch[1];
if (filter && !filter(url)) return;
const altMatch = text.match(RegEx.HTML_ALT);
// Apply filter early
if (filter && !filter(url))
return;
const altMatch = htmlText.match(RegEx.HTML_ALT);
const alt = altMatch?.[1];
images.push({ src: url, alt, from: node.from, to: endNode.to });
images.push({ src: url, alt, from: current.from, to: endNode.to });
}
}
}
@ -196,8 +191,6 @@ const NodeName = {
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.

View file

@ -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}`;
}
}

View file

@ -1,183 +1,11 @@
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";
import { FileView, ItemView, MarkdownView, TextFileView, TFile, View } from "obsidian";
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 type ObsViewMode = "reader" | "preview" | "source";
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);
return ObsAssistant.isInReadingView(view) ? "reader" : (ObsAssistant.isInSourceMode(view) ? "source" : "preview");
}
/** See also {@link View#getViewType()}. */
@ -190,89 +18,43 @@ export class ObsAssistant {
return "FileView";
if (view instanceof ItemView)
return "ItemView";
if (view instanceof View)
return "View";
Env.dev.assert(false, "Expected View");
return "NA";
return "View";
}
public static isInReadingView(view: View) {
return view.getState()?.mode === "preview";
}
public static isInLivePreview(view: View) {
const state = view.getState();
return state ? state.mode === "source" && state.source === false : false;
}
public static isInSourceMode(view: View) {
const state = view.getState();
return state ? state.mode === "source" && state.source === true : false;
}
/** @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
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 workspaces
* 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 workspaces active view.
*/
public static getActiveView(app: App): View | null {
return app.workspace.getActiveViewOfType(View);
}
public static readerContainerEl(someAncestorEl: HTMLElement): HTMLDivElement | null {
// <div class="workspace-leaf-content" data-type="markdown" data-mode="preview">
// <div class="view-content">
// <div class="markdown-reading-view" style="width: 100%; height: 100%;">
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;
return someAncestorEl.querySelector(Selector.READING_VIEW);
}
}
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,
READING_VIEW: ".markdown-reading-view",
} 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",
}
}

View file

@ -10,8 +10,6 @@ export class Url {
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 = {
@ -65,7 +63,7 @@ export class Url {
try {
const url = new URL(src);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
} catch (error) {
return false;
}
}
@ -83,40 +81,6 @@ export class Url {
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;
}

View file

@ -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();
});
}

View file

@ -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;

View file

@ -1,47 +1,20 @@
{
"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,
"noUncheckedIndexedAccess": true,
"lib": ["DOM", "ES2022"]
},
"include": [
"**/*.ts"
]
}

View file

@ -1,6 +1,4 @@
{
"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",