mirror of
https://github.com/mntno/obsidian-come-down.git
synced 2026-07-22 05:43:15 +00:00
Initial commit
This commit is contained in:
parent
a3752d0a80
commit
4a07f3126a
16 changed files with 1933 additions and 1 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal 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.
|
||||
|
|
@ -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
49
esbuild.config.mjs
Normal 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
46
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
import typeScriptPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import typeScriptParser from "@typescript-eslint/parser";
|
||||
|
||||
// If using Svelte
|
||||
// import sveltePlugin from "eslint-plugin-svelte";
|
||||
// import svelteParser from "svelte-eslint-parser";
|
||||
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
"@typescript-eslint": typeScriptPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parser: typeScriptParser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
sourceType: 'module',
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
//...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"**/*js",
|
||||
"**/node_modules/**",
|
||||
],
|
||||
},
|
||||
];
|
||||
162
main.ts
Normal file
162
main.ts
Normal 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
10
manifest.json
Normal 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
37
package.json
Normal 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
1397
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
120
src/CacheManager.ts
Normal file
120
src/CacheManager.ts
Normal 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
46
src/SettingTab.ts
Normal 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
24
tsconfig.json
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.8.0"
|
||||
}
|
||||
Loading…
Reference in a new issue