diff --git a/main.ts b/main.ts index 6aee12f..70bc99d 100644 --- a/main.ts +++ b/main.ts @@ -2,6 +2,7 @@ import { MarkdownView, Notice, Plugin, requestUrl, TFile } from 'obsidian'; import { findLineForAnchor } from './src/anchor'; import { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './src/cache'; +import { CacheManager } from './src/cache-manager'; import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards'; import { resolveCliPath, summarizeViaClaudeCode, summarizeViaCodex } from './src/cli'; import { @@ -30,11 +31,9 @@ import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll'; import { CACHE_SCHEMA_VERSION, cacheEntryMatches, - DEFAULT_MAX_CACHE_ENTRIES, DEFAULT_SETTINGS, generationFingerprint, getApiBaseUrl, - hashContent, isApiBackend, modelForApi, normalizeSettings, @@ -106,12 +105,14 @@ function cancellationNoticeKey(settings: PluginSettings | null, job: GenerationJ class ParallelReaderPlugin extends Plugin { settings!: PluginSettings; - cache!: Record; + cacheManager!: CacheManager; jobs!: GenerationJobManager; _scrollDispose: (() => void) | null = null; _settingsSaveTimer: ReturnType | null = null; - _cacheSaveTimer: ReturnType | null = null; - _cacheDirty = false; + + get cache(): Record { + return this.cacheManager.cache; + } t(key: string, vars?: Record) { return translate(this.settings || DEFAULT_SETTINGS, key, vars); @@ -120,8 +121,6 @@ class ParallelReaderPlugin extends Plugin { async onload() { await this.loadSettings(); this.jobs = new GenerationJobManager(); - this._cacheSaveTimer = null; - this._cacheDirty = false; this.addRibbonIcon('book-open', this.t('ribbonOpen'), async () => { const active = this.getActiveView(); @@ -171,7 +170,7 @@ class ParallelReaderPlugin extends Plugin { callback: async () => { const active = this.getActiveView(); if (!active?.file) return new Notice(this.t('noCurrentNote')); - await this.cacheDelete(active.file.path); + await this.cacheManager.delete(active.file.path); new Notice(this.t('cacheClearedFile', { name: active.file.basename })); }, }); @@ -179,8 +178,8 @@ class ParallelReaderPlugin extends Plugin { id: 'parallel-reader-clear-all', name: this.t('cmdClearAll'), callback: async () => { - const n = Object.keys(this.cache).length; - await this.cacheClear(); + const n = Object.keys(this.cacheManager.cache).length; + await this.cacheManager.clear(); new Notice(this.t('cacheClearedAll', { count: n })); }, }); @@ -228,7 +227,13 @@ class ParallelReaderPlugin extends Plugin { const data = (await this.loadData()) || {}; const settingsBlob = data.settings || {}; this.settings = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settingsBlob)); - await this.loadCache(); + this.cacheManager = new CacheManager( + this.app.vault.adapter, + this.app.vault.configDir || '.obsidian', + this.manifest?.id || 'parallel-reader', + () => this.settings, + ); + await this.cacheManager.load(); } async saveSettings() { @@ -254,139 +259,25 @@ class ParallelReaderPlugin extends Plugin { await this.saveSettings(); } - /* ---------- Cache persistence ---------- */ - - async saveCache() { - if (this._cacheSaveTimer) { - clearTimeout(this._cacheSaveTimer); - this._cacheSaveTimer = null; - } - this.pruneCache(); - await this.writeCacheFile(); - this._cacheDirty = false; - } - - scheduleCacheSave(delayMs = 5000) { - this._cacheDirty = true; - if (this._cacheSaveTimer) return; - this._cacheSaveTimer = setTimeout(() => { - this._cacheSaveTimer = null; - if (!this._cacheDirty) return; - this.saveCache().catch((e) => console.error('[parallel-reader] failed to save cache', e)); - }, delayMs); - } - - async flushCacheSave() { - if (this._cacheSaveTimer) { - clearTimeout(this._cacheSaveTimer); - this._cacheSaveTimer = null; - } - if (!this._cacheDirty) return; - await this.saveCache(); - } - - cacheFilePath() { - const configDir = this.app.vault.configDir || '.obsidian'; - const pluginId = this.manifest?.id || 'parallel-reader'; - return `${configDir}/plugins/${pluginId}/cache.json`; - } - - async ensurePluginDataDir() { - const adapter = this.app.vault.adapter; - const configDir = this.app.vault.configDir || '.obsidian'; - const pluginId = this.manifest?.id || 'parallel-reader'; - const dir = `${configDir}/plugins/${pluginId}`; - try { - if (typeof adapter.exists === 'function' && (await adapter.exists(dir))) return; - await adapter.mkdir(dir); - } catch (_) { - /* ignore race */ - } - } - - async readCacheFile() { - const adapter = this.app.vault.adapter; - try { - const raw = await adapter.read(this.cacheFilePath()); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') - return parsed.entries; - } catch (e: unknown) { - const message = String((e as Error)?.message || e || ''); - if (!/not found|does not exist|ENOENT/i.test(message)) - console.warn('[parallel-reader] failed to read cache.json', e); - } - return {}; - } - - async writeCacheFile() { - await this.ensurePluginDataDir(); - await this.app.vault.adapter.write(this.cacheFilePath(), serializeCacheFile(this.cache)); - } - - async loadCache() { - this.cache = await this.readCacheFile(); - const pruned = this.pruneCache(); - if (pruned.length > 0) await this.writeCacheFile(); - } - - pruneCache() { - return pruneCacheEntries(this.cache, this.settings?.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES); - } - async pruneCacheIfNeeded() { - const removed = this.pruneCache(); - if (removed.length > 0) await this.saveCache(); - return removed; - } - cacheGet(filePath: string) { - return this.cache[filePath] || null; - } + /* ---------- Cache delegation ---------- */ async cacheTouch(filePath: string) { - const entry = touchCacheEntry(this.cache[filePath] || null); - if (!entry) return null; - this.scheduleCacheSave(); - return entry; + return this.cacheManager.touch(filePath); } - - async cachePut(filePath: string, content: string, cards: RawCard[], settings: PluginSettings) { - const now = new Date().toISOString(); - this.cache[filePath] = { - schemaVersion: CACHE_SCHEMA_VERSION, - contentHash: hashContent(content), - settingsHash: generationFingerprint(settings || this.settings), - cards, - generatedAt: now, - lastAccessedAt: now, - }; - await this.saveCache(); + scheduleCacheSave(delayMs = 5000) { + this.cacheManager.scheduleSave(delayMs); + } + async flushCacheSave() { + await this.cacheManager.flush(); } - async cacheReplaceCards(filePath: string, cards: ResolvedCard[]) { - const entry = this.cache[filePath]; - if (!entry) return false; - const now = new Date().toISOString(); - entry.cards = (cards || []).map((card: ResolvedCard) => ({ - title: card.title, - anchor: card.anchor, - gist: card.gist, - bullets: card.bullets || [], - })); - entry.updatedAt = now; - entry.lastAccessedAt = now; - await this.saveCache(); - return true; - } - - async cacheDelete(filePath: string) { - if (this.cache[filePath]) { - delete this.cache[filePath]; - await this.saveCache(); - } + return this.cacheManager.replaceCards(filePath, cards); } async cacheClear() { - this.cache = {}; - await this.saveCache(); + return this.cacheManager.clear(); + } + async pruneCacheIfNeeded() { + return this.cacheManager.pruneIfNeeded(); } /* ---------- View management ---------- */ @@ -480,13 +371,13 @@ class ParallelReaderPlugin extends Plugin { .setIcon('refresh-cw') .onClick(() => this.runForFile(file, true)), ); - if (this.cacheGet(file.path)) { + if (this.cacheManager.get(file.path)) { menu.addItem((it: any) => it .setTitle(this.t('fileMenuClear')) .setIcon('trash') .onClick(async () => { - await this.cacheDelete(file.path); + await this.cacheManager.delete(file.path); new Notice(this.t('cacheClearedFile', { name: file.basename })); }), ); @@ -499,19 +390,16 @@ class ParallelReaderPlugin extends Plugin { const isMarkdown = file.path.endsWith('.md'); if (!wasMarkdown && !isMarkdown) return; if (wasMarkdown && !isMarkdown) { - if (this.cache[oldPath]) { - delete this.cache[oldPath]; - await this.saveCache(); - } + await this.cacheManager.delete(oldPath); const view = this.getParallelView(); if (view && view.sourceFile?.path === oldPath) view.renderEmpty(); return; } if (!wasMarkdown) return; - if (this.cache[oldPath]) { - this.cache[file.path] = this.cache[oldPath]; - delete this.cache[oldPath]; - await this.saveCache(); + if (this.cacheManager.cache[oldPath]) { + this.cacheManager.cache[file.path] = this.cacheManager.cache[oldPath]; + delete this.cacheManager.cache[oldPath]; + await this.cacheManager.save(); } const view = this.getParallelView(); if (view?.sourceFile && (view.sourceFile.path === oldPath || view.sourceFile.path === file.path)) { @@ -522,10 +410,7 @@ class ParallelReaderPlugin extends Plugin { async handleFileDelete(file: TFile) { if (!(file instanceof TFile)) return; - if (this.cache[file.path]) { - delete this.cache[file.path]; - await this.saveCache(); - } + await this.cacheManager.delete(file.path); const view = this.getParallelView(); if (view?.sourceFile?.path === file.path) view.renderEmpty(); } @@ -579,7 +464,7 @@ class ParallelReaderPlugin extends Plugin { new Notice(this.t('alreadyGenerating')); return; } - if (shouldConfirmRegenerate(this.cacheGet(file.path), force) && !this.confirmRegenerateEditedCards()) { + if (shouldConfirmRegenerate(this.cacheManager.get(file.path), force) && !this.confirmRegenerateEditedCards()) { new Notice(this.t('regenerateCancelled')); return; } @@ -600,8 +485,8 @@ class ParallelReaderPlugin extends Plugin { job.setPhase('cache-check'); if (!force) { - const entry = this.cacheGet(file.path); - if (cacheEntryMatches(entry, content, this.settings)) { + const entry = this.cacheManager.get(file.path); + if (entry && cacheEntryMatches(entry, content, this.settings)) { await this.cacheTouch(file.path); if (this.activeFileStillMatches(file)) await view.loadFor(file, this.resolveCardAnchors(content, entry.cards), false); @@ -630,7 +515,7 @@ class ParallelReaderPlugin extends Plugin { } const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets })); job.setPhase('saving'); - await this.cachePut(file.path, content, rawCards, this.settings); + await this.cacheManager.put(file.path, content, rawCards, this.settings); job.throwIfCancelled(); if (this.viewIsShowingFile(view, file)) await view.loadFor(file, sections, false); const unanchored = sections.filter((s) => s.startLine < 0).length; @@ -684,7 +569,7 @@ class ParallelReaderPlugin extends Plugin { if (leaves.length === 0) return; const view = leaves[0].view as ParallelReaderView; if (!view || !this.activeFileStillMatches(file)) return; - const entry = this.cacheGet(file.path); + const entry = this.cacheManager.get(file.path); if (!entry) { await view.loadFor(file, [], false); view.renderEmptyWithHint(file); diff --git a/src/cache-manager.ts b/src/cache-manager.ts new file mode 100644 index 0000000..90b5c63 --- /dev/null +++ b/src/cache-manager.ts @@ -0,0 +1,157 @@ +'use strict'; + +import type { DataAdapter } from 'obsidian'; +import { serializeCacheFile, touchCacheEntry } from './cache'; +import { + CACHE_SCHEMA_VERSION, + DEFAULT_MAX_CACHE_ENTRIES, + generationFingerprint, + hashContent, + pruneCacheEntries, +} from './settings'; +import type { CacheEntry, PluginSettings, RawCard, ResolvedCard } from './types'; + +export class CacheManager { + cache: Record = {}; + private _timer: ReturnType | null = null; + private _dirty = false; + + constructor( + private readonly adapter: DataAdapter, + private readonly configDir: string, + private readonly pluginId: string, + private readonly getSettings: () => PluginSettings, + ) {} + + filePath(): string { + return `${this.configDir}/plugins/${this.pluginId}/cache.json`; + } + + async ensureDir(): Promise { + const dir = `${this.configDir}/plugins/${this.pluginId}`; + try { + if (typeof this.adapter.exists === 'function' && (await this.adapter.exists(dir))) return; + await this.adapter.mkdir(dir); + } catch (_) { + /* ignore race */ + } + } + + async readFile(): Promise> { + try { + const raw = await this.adapter.read(this.filePath()); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') + return parsed.entries; + } catch (e: unknown) { + const message = String((e as Error)?.message || e || ''); + if (!/not found|does not exist|ENOENT/i.test(message)) + console.warn('[parallel-reader] failed to read cache.json', e); + } + return {}; + } + + async writeFile(): Promise { + await this.ensureDir(); + await this.adapter.write(this.filePath(), serializeCacheFile(this.cache)); + } + + async load(): Promise { + this.cache = await this.readFile(); + const pruned = this.prune(); + if (pruned.length > 0) await this.writeFile(); + } + + prune(): string[] { + const settings = this.getSettings(); + return pruneCacheEntries(this.cache, settings?.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES); + } + + async pruneIfNeeded(): Promise { + const removed = this.prune(); + if (removed.length > 0) await this.save(); + return removed; + } + + async save(): Promise { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + this.prune(); + await this.writeFile(); + this._dirty = false; + } + + scheduleSave(delayMs = 5000): void { + this._dirty = true; + if (this._timer) return; + this._timer = setTimeout(() => { + this._timer = null; + if (!this._dirty) return; + this.save().catch((e) => console.error('[parallel-reader] failed to save cache', e)); + }, delayMs); + } + + async flush(): Promise { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + if (!this._dirty) return; + await this.save(); + } + + get(filePath: string): CacheEntry | null { + return this.cache[filePath] || null; + } + + async touch(filePath: string): Promise { + const entry = touchCacheEntry(this.cache[filePath] || null); + if (!entry) return null; + this.cache[filePath] = entry; + this.scheduleSave(); + return entry; + } + + async put(filePath: string, content: string, cards: RawCard[], settings: PluginSettings): Promise { + const now = new Date().toISOString(); + this.cache[filePath] = { + schemaVersion: CACHE_SCHEMA_VERSION, + contentHash: hashContent(content), + settingsHash: generationFingerprint(settings || this.getSettings()), + cards, + generatedAt: now, + lastAccessedAt: now, + }; + await this.save(); + } + + async replaceCards(filePath: string, cards: ResolvedCard[]): Promise { + const entry = this.cache[filePath]; + if (!entry) return false; + const now = new Date().toISOString(); + entry.cards = (cards || []).map((card: ResolvedCard) => ({ + title: card.title, + anchor: card.anchor, + gist: card.gist, + bullets: card.bullets || [], + })); + entry.updatedAt = now; + entry.lastAccessedAt = now; + await this.save(); + return true; + } + + async delete(filePath: string): Promise { + if (this.cache[filePath]) { + delete this.cache[filePath]; + await this.save(); + } + } + + async clear(): Promise { + this.cache = {}; + await this.save(); + } +}