diff --git a/manifest.json b/manifest.json index ab701a8..f178d93 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "come-down", "name": "Come Down", - "version": "1.0.1", + "version": "1.0.2", "minAppVersion": "1.8.0", "description": "Maintains a cache of your notes’ embedded external images.", "author": "mntno", diff --git a/src/HtmlAssistant.ts b/src/HtmlAssistant.ts index 78c5e20..781873c 100644 --- a/src/HtmlAssistant.ts +++ b/src/HtmlAssistant.ts @@ -36,7 +36,11 @@ export const enum HTMLElementCacheState { CACHE_FAILED, /** - * For example, the url doesn't exist (404). + * For example, + * - the url doesn't exist (404) + * - the url exists but is irrelevant, e.g., https://example.com/ + * + * Elements in this state are ignored. The may or may not have an icon set ({@link HtmlAssistant.setIcon}), e.g., if the URL was requested but resulted in 404. */ INVALID, } diff --git a/src/Settings.ts b/src/Settings.ts index 9b5ad63..eba901f 100644 --- a/src/Settings.ts +++ b/src/Settings.ts @@ -140,20 +140,13 @@ export class SettingTab extends PluginSettingTab { // This is more of an "info setting" assuring that a .gitignore file exists rather than allowing its removal. new Setting(containerEl) .setName("Exclude cache from Git") - .setDesc(`Use a \`.gitignore\` file to prevent cached files from being visible to Git. Note that this option cannot be disabled here.`) + .setDesc(`Use a \`.gitignore\` file to prevent cached files from being visible to Git. Note that this option cannot be disabled here.`) + .setClass("come-down-toggle-disabled") .addToggle((toggle) => { const refreshDisabled = () => { if (settings.gitIgnoreCacheDir) { toggle.setDisabled(true); - - // - Found no CSS variable to convey that "the setting toggle is enabled yet you cannot disable it". - // - Adding a styles.css file seems too much as this is the only place where CSS is modified. - toggle.toggleEl.setCssStyles({ - cursor: "not-allowed", - opacity: "0.6", - filter: "contrast(80%)" - }); } } diff --git a/src/Url.ts b/src/Url.ts index 988cc06..b8e8ffd 100644 --- a/src/Url.ts +++ b/src/Url.ts @@ -59,6 +59,10 @@ export class Url { return url.startsWith("app://") || url.startsWith("capacitor://") || url.startsWith("file://"); } + public static trimBackslash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; + } + /** * @author Gemini * @param url diff --git a/src/Workarounds.ts b/src/Workarounds.ts new file mode 100644 index 0000000..895cca5 --- /dev/null +++ b/src/Workarounds.ts @@ -0,0 +1,108 @@ +import { Log } from "Environment"; +import { ViewUpdate } from "@codemirror/view"; +import { Url } from "Url"; +import { HtmlAssistant, HTMLElementAttribute, HTMLElementCacheState } from "HtmlAssistant"; + + +export class Workarounds { + + /** + * If there is markdown link at any point after an empty embedded syntax + * (i.e., "\!\[\]\(\)" or "\!\[\]\(") and `img` element will be insterted with the `src` attribute + * set to the `href` of the link! + * + * Therefore, when the user types "\!\[\]\(\)" the plugin will start downloading the `href` url. + * + * Outstanding related issues: + * + * - As this method only looks at changes, if a note is loaded containing "\!\[\]\(\)" or "\!\[\]\(") with a Markdown link after it, it slips through. + * - When note contain "\!\[\]\(", moving the cursor around seems to reset the inserted `img`, which will trigger download. + * - If the Markdown link is modified while there is a "\!\[\]\(\)" or "\!\[\]\(" above it. + * - If there is a code block after "\!\[\]\(\)" or "\!\[\]\(" containing a Markdown link this link will not cause the issue to arise, but the regex matches with that link rather than any link after the code block which is the real cause. + * Pursuing this particular edge case is not worth it because it will likely include to regex searches to discard Markdown links in code blocks. + * + * The additional processing in these rare edge cases might not be worth addressing. + * The important part is to prevent downloads when the user is engaging with the link. + * + * @param update + * @returns The URLs of `img` elements in the DOM that should be ignored or `null` if there are none. + */ + public static detectSourcesOfInvalidImageElements(update: ViewUpdate) { + if (update.changes.empty) + return null; + + var sourcesToIgnore: string[] = []; + + update.changes.iterChanges((_fromA, _toA, cursorStartPos, cursorEndPosition, inserted) => { + + const insertedText = inserted.toString(); + const doc = update.state.doc; + const line = doc.lineAt(cursorStartPos); + const modifiedLine = doc.sliceString(line.from, line.to) + "\n"; + + Log(`Workarounds: Inserted ${cursorStartPos}/${cursorEndPosition}: ${insertedText}`); + Log(`Workarounds: Modified line: ${modifiedLine}, match: ${this.embeddedImageRegex.test(modifiedLine)}`); + + if (this.embeddedImageRegex.test(modifiedLine)) { + + // Scan for the first markdown link after the cursor + const textAfterCursor = doc.sliceString(cursorEndPosition); + const match = this.markdownLinkRegex.exec(textAfterCursor); + + //Log(`Text after: ${textAfterCursor}`); + + if (match) { + const linkUrl = match[1]; + Log(`Workarounds: Found image src to ignore: ${linkUrl}`); + + // The image element is only inserted when the link protocol is recognized, i.e., + // when it begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`. + // So no need to check if the url is valid or external here. Insert. + sourcesToIgnore.push(linkUrl); + } + } + }); + + return sourcesToIgnore.length > 0 ? sourcesToIgnore : null; + } + + /** + * Matches all cases where the invalid image element is inserted. + * + * - \!\[\]\(\) + * - \!\[\]\( + * - \!\[abc\]\(\) + * - \!\[\]\(abc + */ + private static readonly embeddedImageRegex = /!\[(.*?)\]\((.*?)\)?/; + + /** + * Use to find the first Markdown link in a string. + * + * Will not match if there's no link because an img element is not inserted until the link + * begins with `http:`, `https:`, `ftp:`, `ws:`, or `wss:`. + */ + private static readonly markdownLinkRegex = /\[.*?\]\((.+?)\)/; + + + /** + * + * @param sourcesToIgnore Result of having called {@link detectSourcesOfInvalidImageElements} + * @param imageElement + * @param src The `src` of the {@link imageElement} parameter. Check existance before calling. + * @returns `false` when the {@link imageElement}'s state was set to {@link HTMLElementCacheState.INVALID} and should be filtered out of furter processing. + */ + public static HandleInvalidImageElements(sourcesToIgnore: string[] | null, imageElement: HTMLImageElement, src: string): boolean { + if (sourcesToIgnore) { + for (const sourceToIgnore of sourcesToIgnore) { + if (Url.trimBackslash(sourceToIgnore) === Url.trimBackslash(src)) { + imageElement.removeAttribute(HTMLElementAttribute.SRC); // Shouldn't be necessary but why keep it. + HtmlAssistant.setCacheState(imageElement, HTMLElementCacheState.INVALID); // Set to invalid so that the next pass ignores it. + return false; + } + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 0ab56a7..4702764 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { HTMLElementAttribute, HTMLElementCacheState, HtmlAssistant } from "Html import { InfoModal } from "InfoModal"; import { ProcessingPass } from "ProcessingPass"; import { Url } from "Url"; +import { Workarounds } from "Workarounds"; interface PluginData { settings: PluginSettings; @@ -76,7 +77,7 @@ export default class ComeDownPlugin extends Plugin { this.addCommand({ id: "open-info-modal", - name: "Open Cacheboard", + name: "Cacheboard", callback: () => { new InfoModal(this.app, this.cacheManager, this.settingsManager.settings).open(); } @@ -190,52 +191,69 @@ export default class ComeDownPlugin extends Plugin { private editorViewUpdateListener(update: ViewUpdate) { + const sourcesToIgnore = Workarounds.detectSourcesOfInvalidImageElements(update); + const processingPass = ProcessingPass.beginFromViewUpdate(this.app, update, () => { - // There is no file to work with. All that can be done is to cancel loading all external urls. + // There is no file to work with. All that can be done is to cancel loading. const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, true, (imageElement) => { const src = imageElement.getAttribute(HTMLElementAttribute.SRC); - return src !== null && Url.isValid(src) && Url.isExternal(src); + + // Filter out image elements without a src or invalid. + if (src === null || !Workarounds.HandleInvalidImageElements(sourcesToIgnore, imageElement, src)) + return false; + + // Allow image elements with external urls through so they can be cancelled. + return Url.isValid(src) && Url.isExternal(src); }); HtmlAssistant.cancelImageLoading(imageElements); }); - if (processingPass) { + if (!processingPass) + return; - // Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements. - const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, false, (imageElement) => { - const src = imageElement.getAttribute(HTMLElementAttribute.SRC); + // Elements in DOM at this stage might be in states in which the `src` attribute has been removed. Therefore the `src` attribute is not required when finding image elements. + const imageElements = HtmlAssistant.findRelevantImagesToProcess(update.view.contentDOM, false, (imageElement) => { + const src = imageElement.getAttribute(HTMLElementAttribute.SRC); - // 1. The user is editing the link (causing the source attribute to change) which resets the element's state. - // External urls are only accepted in the element's original state. - if (update.docChanged && src && Url.isExternal(src) && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL) - HtmlAssistant.resetElement(imageElement); // Set state to "untouched". - - // 2. As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained. - if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) { - const src = HtmlAssistant.originalSrc(imageElement); - console.assert(src !== null, "Expected original source dataset"); - if (src) - processingPass.retainCacheFromRequest({ source: src, requesterPath: processingPass.associatedFile.path }); - } - - // 3. Start filtering: If the image element has a src, only keep external urls; if not, keep all. - let isSrcOk = src !== null ? Url.isValid(src) && Url.isExternal(src) : true; + // 1. The user is editing the link (causing the source attribute to change) which resets the element's state. + // External urls are only accepted in the element's original state. + if (update.docChanged && src && Url.isExternal(src) && HtmlAssistant.cacheState(imageElement) != HTMLElementCacheState.ORIGINAL) + HtmlAssistant.resetElement(imageElement); // Set state to "untouched". - // 4. If the url was ok, then remove all states that have passed this stage already. - return isSrcOk && this.filterIrrelevantCacheStates(imageElement); - }); - - if (imageElements.length > 0) { - HtmlAssistant.cancelImageLoading(imageElements); - this.requestCache(imageElements, processingPass); - } - else { - // Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done. - if (update.docChanged) - processingPass.end(this.cacheManager); - else - processingPass.abort(); + // 2. As all images are retained in each pass, even though elements that are already cached are excluded from further processing, they still need to be retained. + if (HtmlAssistant.cacheState(imageElement) == HTMLElementCacheState.CACHE_SUCCEEDED) { + const src = HtmlAssistant.originalSrc(imageElement); + console.assert(src !== null, "Expected original source dataset"); + if (src) + processingPass.retainCacheFromRequest({ source: src, requesterPath: processingPass.associatedFile.path }); } + + // 3. If there's no src there's nothing left to do but to remove all states that have passed this stage already. + if (src === null) + return this.filterIrrelevantCacheStates(imageElement); + + // 4. Only external urls are relevant. + if (!(Url.isValid(src) && Url.isExternal(src))) + return false; + + // 5. Filter out invalid. + if (!Workarounds.HandleInvalidImageElements(sourcesToIgnore, imageElement, src)) + return false; + + // 5. Remove all states that have passed this stage already. + return this.filterIrrelevantCacheStates(imageElement); + }); + + if (imageElements.length > 0) { + HtmlAssistant.cancelImageLoading(imageElements); + this.requestCache(imageElements, processingPass); + } + else { + // Special case when the user deletes an existing embed and all other image elements were filtered out: there are either no other embeds or all the other are already done. + if (update.docChanged) + processingPass.end(this.cacheManager); + else + processingPass.abort(); } } diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..b139598 --- /dev/null +++ b/styles.css @@ -0,0 +1,6 @@ +.come-down-toggle-disabled .checkbox-container.is-disabled { + cursor: not-allowed; + opacity: 0.75; + filter: grayscale(30%) contrast(90%); +} + diff --git a/versions.json b/versions.json index c14b6e2..b29d450 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { + "1.0.2": "1.8.0", "1.0.1": "1.8.0", "1.0.0": "1.8.0" }