Initial commit

This commit is contained in:
Mantano 2025-03-04 14:49:03 +07:00
parent a3752d0a80
commit 4a07f3126a
16 changed files with 1933 additions and 1 deletions

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 2
tab_width = 2
trim_trailing_whitespace = true

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# vscode
.vscode
# npm
node_modules
# Hot Reload plugin
.hotreload
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

5
LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2025 by Mantano Bhikkhu.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View file

@ -1,3 +1,3 @@
# Come Down
An Obsidian plugin for caching embedded external images.
An Obsidian plugin that caches embedded external images to your local machine.

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

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

162
main.ts Normal file
View file

@ -0,0 +1,162 @@
import { MarkdownView, Plugin, editorLivePreviewField, editorInfoField, MarkdownFileInfo, MarkdownPostProcessorContext, Notice, debounce, normalizePath } from "obsidian";
import { SettingTab, SettingsManager } from "src/SettingTab";
import { CacheManager, CacheMetadataItem, CacheResourceType } from "src/CacheManager";
import { EditorView, ViewUpdate } from "@codemirror/view";
export default class PluginImplementation extends Plugin {
settingsManager: SettingsManager;
cacheManager: CacheManager;
async onload() {
const pluginDir = this.manifest.dir;
if (!pluginDir) {
const errorMsg = `${this.manifest.name}: Cannot load because plugin directory is unknown`;
new Notice(errorMsg, 0);
throw new Error(errorMsg);
}
this.cacheManager = new CacheManager(this.app, pluginDir + "/cache");
this.settingsManager = new SettingsManager(
await this.loadData(),
async (settings) => await this.saveData(settings),
);
this.addSettingTab(new SettingTab(this, this.settingsManager));
// The post processor runs after the Markdown has been processed into HTML. It lets you add, remove, or replace HTML elements to the rendered document.
this.registerMarkdownPostProcessor((element, context) => {
this.processReadingView(element, context);
});
// Handle live preview
this.registerEditorExtension(EditorView.updateListener.of((update: ViewUpdate) => {
this.processLivePreview(update);
}));
this.addCommand({
id: "clear-all-cache",
name: "Clear All Cached Files",
callback: () => {
this.cacheManager.clearCached();
}
});
this.addCommand({
id: 'redownload-all-images-in-this-file',
name: 'Redownload all images in this file.',
checkCallback: (checking: boolean) => {
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
if (!checking) {
}
return true;
}
}
});
}
onunload() {
this.cacheManager?.cancelAllOngoing();
}
processLivePreview(update: ViewUpdate) {
const view = update.view;
const isInLivePreview = view.state.field(editorLivePreviewField);
if (!isInLivePreview)
return;
if (update.docChanged || update.viewportChanged) {
const imageElements = view.contentDOM.findAll("img") as HTMLImageElement[];
/* Gave up with this. Not sure if it's more efficient.
syntaxTree(view.state).iterate({
enter: ({ type, from, to }: SyntaxNodeRef) => {
console.log(`${view.state.doc.sliceString(from, to)}`);
}
});
*/
const markdownView = view.state.field(editorInfoField) as MarkdownFileInfo;
this.handleImages(imageElements, markdownView.file?.path);
}
}
processReadingView(element: HTMLElement, context: MarkdownPostProcessorContext) {
const imageElements = element.findAll("img") as HTMLImageElement[];
this.handleImages(imageElements, context.sourcePath);
}
/**
* Initiates a call to the {@link CacheManager} for each image element and changing the src to the cached file.
*
* @param imageElements All images in the post proceessed HTML.
* @param filePath Path to the file containing this HTML.
*/
handleImages(imageElements: HTMLImageElement[], filePath: string | undefined) {
//if (filePath)
// this.cacheManager.cancelOngoing(filePath);
imageElements
.filter((imageElement => imageElement.src.length > 0)) // In case src hasn't been set yet.
.filter((imageElement) => imageElement.dataset.comeDownCacheInitiated !== "true" ) // Avoid stampede.
.forEach((imageElement) => {
const metadata: CacheMetadataItem = {
key: imageElement.src,
filePath: filePath,
type: CacheResourceType.ExternalImage
};
const originalAltText = imageElement.alt;
imageElement.alt = "Loading...";
imageElement.dataset.comeDownCacheInitiated = "true";
imageElement.src = `data:image/svg+xml;charset=utf-8,${PluginImplementation.ENCODED_LOADING_ICON}`;
console.log(`handleImage: ${metadata.key}, alt: ${originalAltText}`);
this.cacheManager.getCache(metadata, (result) => {
imageElement.alt = originalAltText;
if (result.item) {
console.log(`Got CacheResult from cache: ${result.fromCache}, src: ${result.item?.metadata.key}`);
imageElement.src = result.item.filePath;
}
else
console.error("Failed to fetch cache", result.error);
});
});
}
/**
* @todo
* @see {@link https://lucide.dev/icons/loader}
*/
static readonly SVG_LOADING_ICON = `
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="transparant"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v4" />
<path d="m16.2 7.8 2.9-2.9" />
<path d="M18 12h4" />
<path d="m16.2 16.2 2.9 2.9" />
<path d="M12 18v4" />
<path d="m4.9 19.1 2.9-2.9" />
<path d="M2 12h4" />
<path d="m4.9 4.9 2.9 2.9" />
</svg>
`;
static readonly ENCODED_LOADING_ICON = encodeURIComponent(PluginImplementation.SVG_LOADING_ICON);
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "come-down",
"name": "Come Down",
"version": "1.0.0",
"minAppVersion": "1.8.0",
"description": "Caches embedded external images to your local machine without modifying your notes.",
"author": "mntno",
"authorUrl": "https://github.com/mntno",
"isDesktopOnly": false
}

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "come-down",
"version": "1.0.0",
"description": "An Obsidian plugin for caching embedded external images.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "mntno",
"license": "0BSD",
"devDependencies": {
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"@eslint/js": "9.21.0",
"@lezer/common": "^1.2.3",
"@types/node": "22.13.9",
"@typescript-eslint/eslint-plugin": "8.26.0",
"@typescript-eslint/parser": "8.26.0",
"builtin-modules": "5.0.0",
"esbuild": "0.25.0",
"globals": "16.0.0",
"obsidian": "latest",
"tslib": "2.8.1",
"typescript": "5.8.2",
"typescript-eslint": "8.26.0"
},
"type": "module",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}

1397
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

120
src/CacheManager.ts Normal file
View file

@ -0,0 +1,120 @@
import { App } from "obsidian";
export interface CacheMetadataItem {
/** Unique key to identify the cached item. */
key: string;
/** Path to the associated file. */
filePath?: string;
type: CacheResourceType;
}
export interface CacheItem {
metadata: CacheMetadataItem;
/** Path to the cached file. */
filePath: string;
}
export interface CacheResult {
item: CacheItem | undefined;
/** Whether this `item` was fetched from the cache. */
fromCache: boolean;
error: Error | undefined;
}
export enum CacheResourceType {
Undefined = 0,
ExternalImage = 1,
}
export class CacheManager {
private cache: { [key: string]: CacheItem } = {};
private app: App;
private cacheFolderPath: string;
constructor(app: App, cacheFolderPath: string) {
this.app = app;
this.cacheFolderPath = cacheFolderPath;
}
async getCache(metadataItem: CacheMetadataItem, callback: (result: CacheResult) => any) {
if (metadataItem.key.length == 0) {
callback({
item: undefined,
fromCache: false,
error: new Error(`The cache key is not set.`),
});
return;
}
if (!CacheManager.isExternalUrl(metadataItem.key)) {
callback({
item: undefined,
fromCache: false,
error: new Error(`The cache key must be an external Url (${metadataItem.key})`),
});
return;
}
// TODO
if (metadataItem.filePath) {
const file = this.app.vault.getFileByPath(metadataItem.filePath);
if (!file)
return;
}
// Create a result from the cache, assuming it exists.
let cacheResult: CacheResult = {
item: this.cache[metadataItem.key],
fromCache: true,
error: undefined,
}
// Get it if it doesn't.
if (!cacheResult.item) {
await sleep(Math.floor(Math.random() * 101) + 1000);
const cachedItem: CacheItem = {
metadata: metadataItem,
filePath: this.app.vault.adapter.getResourcePath(this.cacheFolderPath + "/image.png"),
};
this.cache[metadataItem.key] = cachedItem;
cacheResult = { item: cachedItem, fromCache: false, error: undefined }
}
callback(cacheResult);
}
clearCached() {
this.cache = {};
}
/**
* Aborts current download requests for the specified file.
* @todo
*/
cancelOngoing(filePath: string) {
}
/**
* Aborts all current download requests.
* @todo
*/
cancelAllOngoing() {
}
private static isExternalUrl(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "http:" || url.protocol === "https:";
} catch (error) {
return false;
}
}
}

46
src/SettingTab.ts Normal file
View file

@ -0,0 +1,46 @@
import { PluginSettingTab, Setting, Plugin } from "obsidian";
interface PluginSettings {
/** `true` to make sure there's a .gitignore file in the cache directory; `false` to make sure otherwise. */
gitIgnoreCacheDir: boolean;
}
const DEFAULT_SETTINGS: PluginSettings = {
gitIgnoreCacheDir: true,
}
export class SettingsManager {
settings: PluginSettings;
save: () => Promise<void>;
constructor(settings: any, save: (settings: PluginSettings) => Promise<void>) {
this.settings = Object.assign({}, DEFAULT_SETTINGS, settings);
this.save = () => save(this.settings);
}
}
export class SettingTab extends PluginSettingTab {
settingsManager: SettingsManager;
constructor(plugin: Plugin, settingsManager: SettingsManager) {
super(plugin.app, plugin);
this.settingsManager = settingsManager;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Exclude cache from Git")
.setDesc("Enable to automatically add a .gitignore file to your Obsidian cache, preventing it from being committed to Git.")
.addToggle((toggle) => {
toggle.setValue(this.settingsManager.settings.gitIgnoreCacheDir);
toggle.onChange(async (value) => {
this.settingsManager.settings.gitIgnoreCacheDir = value;
await this.settingsManager.save();
});
})
}
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.8.0"
}