/* eslint-disable @typescript-eslint/no-explicit-any */ /** * What FineNotes' notebook view expects around it in Obsidian, for the * website playground: an app with a vault kept in memory, and the plugin * object the view calls into. Anything that needs Obsidian itself, an * account or the network answers with a notice pointing to the plugin. * * Original code for this site. GPL-3.0-or-later. */ import { Events, Notice, Scope, TFile, TFolder } from "obsidian"; export const PLUGIN_URL = "https://community.obsidian.md/plugins/finenotes"; // --- "Available in the plugin" --------------------------------------------------------- let shown: Notice | null = null; /** Tell the visitor that `feature` is part of the real plugin, with a link to it. */ export function inThePlugin(feature: string): void { shown?.hide(); const message = document.createDocumentFragment(); message.append(`${feature}: available in the FineNotes plugin for Obsidian, not in this demo. `); const link = document.createElement("a"); link.textContent = "Get FineNotes free"; link.href = PLUGIN_URL; link.target = "_top"; link.rel = "noopener"; link.className = "play-notice-link"; message.append(link); shown = new Notice(message, 6000); } // --- The vault -------------------------------------------------------------------------- const TYPES: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", bmp: "image/bmp", svg: "image/svg+xml", pdf: "application/pdf", md: "text/markdown", }; const folderOf = (path: string): string => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""); const copy = (data: ArrayBuffer | Uint8Array): ArrayBuffer => data instanceof Uint8Array ? (data.slice().buffer as ArrayBuffer) : data.slice(0); /** Files held in this tab's memory, with Obsidian's `Vault` methods over them. */ export class PlaygroundVault extends Events { private readonly bytes = new Map(); private readonly files = new Map(); private readonly folders = new Map(); private readonly urls = new Map(); readonly configDir = ".obsidian"; readonly adapter = { exists: (path: string): Promise => Promise.resolve(this.files.has(path) || this.folders.has(path)), stat: (path: string) => { const file = this.files.get(path); if (file) return Promise.resolve({ type: "file", ...file.stat }); return Promise.resolve(this.folders.has(path) ? { type: "folder", ctime: 0, mtime: 0, size: 0 } : null); }, read: (path: string): Promise => this.readPath(path).then((b) => new TextDecoder().decode(b)), readBinary: (path: string): Promise => this.readPath(path), write: (path: string, text: string): Promise => this.writePath(path, new TextEncoder().encode(text)), writeBinary: (path: string, data: ArrayBuffer): Promise => this.writePath(path, data), mkdir: (path: string): Promise => { this.folder(path); return Promise.resolve(); }, remove: (path: string): Promise => { const file = this.files.get(path); return file ? this.delete(file) : Promise.resolve(); }, list: (path: string) => { const kids = this.folders.get(path)?.children ?? []; return Promise.resolve({ files: kids.filter((k) => k instanceof TFile).map((k) => k.path), folders: kids.filter((k) => k instanceof TFolder).map((k) => k.path), }); }, getResourcePath: (path: string): string => this.urlOf(path), }; constructor() { super(); this.folders.set("", this.makeFolder("")); } getName(): string { return "FineNotes playground"; } private makeFolder(path: string): TFolder { const f = new TFolder(path); f.vault = this; return f; } /** The folder at `path`, made (with its parents) if missing. */ private folder(path: string): TFolder { let f = this.folders.get(path); if (f) return f; const parent = this.folder(folderOf(path)); f = this.makeFolder(path); f.parent = parent; parent.children.push(f); this.folders.set(path, f); return f; } private readPath(path: string): Promise { const b = this.bytes.get(path); return b ? Promise.resolve(b.slice(0)) : Promise.reject(new Error(`${path} does not exist`)); } private async writePath(path: string, data: ArrayBuffer | Uint8Array): Promise { const file = this.files.get(path); if (file) await this.modifyBinary(file, copy(data)); else await this.createBinary(path, data); } /** Store bytes at `path` (new or replaced); returns the file. Fires no event. */ store(path: string, data: ArrayBuffer | Uint8Array): TFile { const bytes = copy(data); this.bytes.set(path, bytes); this.forgetUrl(path); let file = this.files.get(path); const now = Date.now(); if (!file) { file = new TFile(path); file.vault = this; file.parent = this.folder(folderOf(path)); file.parent.children.push(file); file.stat = { ctime: now, mtime: now, size: bytes.byteLength }; this.files.set(path, file); } else { file.stat = { ...file.stat, mtime: now, size: bytes.byteLength }; } return file; } /** Every file and its bytes (for keeping the visitor's notebook between visits). */ entries(): Array<[string, ArrayBuffer]> { return [...this.bytes.entries()]; } getRoot(): TFolder { return this.folders.get("")!; } getFileByPath(path: string): TFile | null { return this.files.get(path) ?? null; } getFolderByPath(path: string): TFolder | null { return this.folders.get(path) ?? null; } getAbstractFileByPath(path: string): TFile | TFolder | null { return this.getFileByPath(path) ?? this.getFolderByPath(path); } getFiles(): TFile[] { return [...this.files.values()]; } getMarkdownFiles(): TFile[] { return this.getFiles().filter((f) => f.extension === "md"); } getAllLoadedFiles(): Array { return [...this.folders.values(), ...this.files.values()]; } getAllFolders(includeRoot = false): TFolder[] { return [...this.folders.values()].filter((f) => includeRoot || !f.isRoot()); } createFolder(path: string): Promise { if (this.folders.has(path)) return Promise.reject(new Error("Folder already exists.")); return Promise.resolve(this.folder(path)); } createBinary(path: string, data: ArrayBuffer | Uint8Array): Promise { if (this.files.has(path)) return Promise.reject(new Error("File already exists.")); const file = this.store(path, data); this.trigger("create", file); return Promise.resolve(file); } create(path: string, text: string): Promise { return this.createBinary(path, new TextEncoder().encode(text)); } modifyBinary(file: TFile, data: ArrayBuffer): Promise { this.store(file.path, data); this.trigger("modify", file); return Promise.resolve(); } modify(file: TFile, text: string): Promise { return this.modifyBinary(file, new TextEncoder().encode(text).buffer as ArrayBuffer); } async append(file: TFile, text: string): Promise { await this.modify(file, (await this.read(file)) + text); } async appendBinary(file: TFile, data: ArrayBuffer): Promise { const old = new Uint8Array(await this.readBinary(file)); const joined = new Uint8Array(old.length + data.byteLength); joined.set(old); joined.set(new Uint8Array(data), old.length); await this.modifyBinary(file, joined.buffer); } async process(file: TFile, fn: (text: string) => string): Promise { const next = fn(await this.read(file)); await this.modify(file, next); return next; } readBinary(file: TFile): Promise { return this.readPath(file.path); } async read(file: TFile): Promise { return new TextDecoder().decode(await this.readBinary(file)); } cachedRead(file: TFile): Promise { return this.read(file); } delete(file: TFile | TFolder): Promise { if (file instanceof TFile && this.files.has(file.path)) { this.files.delete(file.path); this.bytes.delete(file.path); this.forgetUrl(file.path); file.parent?.children.remove(file); this.trigger("delete", file); } return Promise.resolve(); } trash(file: TFile | TFolder): Promise { return this.delete(file); } async rename(file: TFile, newPath: string): Promise { const data = await this.readBinary(file); const oldPath = file.path; await this.delete(file); const moved = this.store(newPath, data); this.trigger("rename", moved, oldPath); } async copy(file: TFile, newPath: string): Promise { return this.createBinary(newPath, await this.readBinary(file)); } getResourcePath(file: TFile): string { return this.urlOf(file.path); } /** A blob: URL for a file, made once and dropped when the file changes. */ private urlOf(path: string): string { const bytes = this.bytes.get(path); if (!bytes) return ""; let url = this.urls.get(path); if (!url) { const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); url = URL.createObjectURL(new Blob([bytes], { type: TYPES[ext] ?? "application/octet-stream" })); this.urls.set(path, url); } return url; } private forgetUrl(path: string): void { const url = this.urls.get(path); if (url) URL.revokeObjectURL(url); this.urls.delete(path); } } // --- The app ---------------------------------------------------------------------------- /** A free path in `folder` for `name`: "name.ext", then "name 2.ext", … */ function freePath(vault: PlaygroundVault, folder: string, name: string): string { const dot = name.lastIndexOf("."); const stem = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot) : ""; const prefix = folder ? `${folder}/` : ""; for (let n = 1; ; n++) { const candidate = `${prefix}${stem}${n === 1 ? "" : ` ${n}`}${ext}`; if (!vault.getAbstractFileByPath(candidate)) return candidate; } } const LOCAL_PREFIX = "finenotes-playground:local:"; export class PlaygroundApp { readonly vault = new PlaygroundVault(); /** Obsidian's root key scope; views hang their own scopes under it. */ readonly scope = new Scope(); /** The notebook view on the page, for `getActiveViewOfType`. */ activeView: unknown = null; /** The file the view shows. */ activeFile: TFile | null = null; readonly workspace: any; readonly metadataCache: any; readonly fileManager: any; readonly setting = { open: () => inThePlugin("Settings"), openTabById: () => inThePlugin("Settings"), close: () => undefined, }; constructor() { const app = this; const vault = this.vault; const events = new Events(); this.workspace = Object.assign(events, { containerEl: document.body, layoutReady: true, onLayoutReady: (cb: () => void) => cb(), getActiveFile: () => app.activeFile, getActiveViewOfType: (type: abstract new (...a: never[]) => unknown) => app.activeView instanceof type ? app.activeView : null, getLeavesOfType: () => [], getLeaf: () => ({ openFile: () => { inThePlugin("Opening other notes"); return Promise.resolve(); }, setViewState: () => Promise.resolve(), detach: () => undefined, }), revealLeaf: () => Promise.resolve(), openLinkText: () => { inThePlugin("Links to other notes"); return Promise.resolve(); }, requestSaveLayout: () => undefined, }); this.metadataCache = Object.assign(new Events(), { resolvedLinks: {}, unresolvedLinks: {}, getFileCache: () => null, getCache: () => null, fileToLinktext: (file: TFile) => file.basename, getFirstLinkpathDest: (link: string) => { const target = link.replace(/[#|].*$/, ""); return ( vault.getFileByPath(target) ?? vault.getFiles().find((f) => f.name === target || f.basename === target) ?? null ); }, }); this.fileManager = { getNewFileParent: () => vault.getRoot(), getAvailablePathForAttachment: (name: string, sourcePath?: string) => Promise.resolve(freePath(vault, folderOf(sourcePath ?? ""), name)), generateMarkdownLink: (file: TFile) => `![[${file.name}]]`, renameFile: (file: TFile, path: string) => vault.rename(file, path), trashFile: (file: TFile) => vault.delete(file), processFrontMatter: () => Promise.resolve(), }; } /** Obsidian's per-vault local storage, kept in this browser's localStorage. */ loadLocalStorage(key: string): unknown { try { const raw = localStorage.getItem(LOCAL_PREFIX + key); return raw === null ? null : JSON.parse(raw); } catch { return null; } } saveLocalStorage(key: string, value: unknown): void { try { if (value === null || value === undefined) localStorage.removeItem(LOCAL_PREFIX + key); else localStorage.setItem(LOCAL_PREFIX + key, JSON.stringify(value)); } catch { // Storage off or full: not kept. } } } // --- The plugin object -------------------------------------------------------------------- /** * The calls InkView makes to keep a choice for next time (the last pen, the * eraser, colours…). Here a choice lasts as long as the tab: nothing to do. */ const KEEP_FOR_NEXT_TIME = [ "saveLastPen", "saveEraser", "saveEraserFilter", "saveLasso", "saveTextTool", "saveTableHeader", "saveShapeStyle", "saveShapeColor", "saveSavedPens", "savePenInks", "savePenGestures", "savePenColors", "savePenAutoShape", "saveRecentColors", "setPressureWidth", "setPenCursor", "setPageAppearance", "setDebugHud", "applyPageAppearance", "offerTextHint", ]; /** The handwriting reader (Grape) is not in the playground: every read fails, as offline would. */ const notHere = (): Promise => Promise.reject(new Error("Handwriting reading works in the FineNotes plugin")); export function playgroundPlugin(app: PlaygroundApp, settings: Record): any { const plugin: Record = { app, settings, manifest: { id: "finenotes", name: "FineNotes", version: "2.3.0", dir: "" }, keys: { secure: false }, providers: new Map(), saveSettings: () => Promise.resolve(), maybeShowScribbleNotice: () => Promise.resolve(), // AI: never set up here. aiSetup: () => ({ vendor: "anthropic", baseUrl: "", imageVendor: "openai", hasKey: () => false }), textAiConfig: () => null, imageAiConfig: () => null, apiKeyFor: () => "", consentedTo: () => false, confirmAiSend: () => Promise.resolve(false), activeProvider: () => ({ id: "manual", requiresNetwork: false }), runRecognition: () => inThePlugin("Transcription"), // On-device reading: switched off (settings.handwritingOnDevice) and stood in for. handwriting: { model: notHere, readPage: notHere, readInk: notHere, readGroups: notHere }, readOnDevice: () => inThePlugin("Reading handwriting"), readingGuard: { paused: true, skipped: () => new Map(), begin() {}, end() {}, fail() {}, setHidden() {} }, learner: { progress: null, start: () => inThePlugin("Learning your handwriting"), stop() {}, subscribe: () => () => undefined }, learningHost: () => ({ app, settings }), // No other files are ever moved in the playground. fileMoves: { log: () => [], relink: () => undefined }, openSettingsTab: () => inThePlugin("Settings"), openTemplateGallery: () => inThePlugin("The template gallery"), openShareTemplate: () => inThePlugin("Sharing templates"), openNewNotebookDialog: () => inThePlugin("More notebooks"), }; for (const name of KEEP_FOR_NEXT_TIME) plugin[name] = () => undefined; // A call this list does not know about must not break the page: it does nothing. return new Proxy(plugin, { get(target, key) { if (typeof key !== "string" || key in target || key === "then") return target[key as string]; return () => undefined; }, }); }