refactor: extract CacheManager from main.ts

Move all cache persistence methods and state (_cacheSaveTimer, _cacheDirty)
into a new CacheManager class in src/cache-manager.ts. Plugin delegates to
it via thin wrappers that preserve the existing method names tested by the
test suite. main.ts reduced from 826 to 713 lines.

https://claude.ai/code/session_016QvEfqw6YZ3RjwBHrJ4w8S
This commit is contained in:
Claude 2026-04-26 06:07:03 +00:00
parent f86f45fe73
commit 87904811e2
No known key found for this signature in database
2 changed files with 198 additions and 156 deletions

197
main.ts
View file

@ -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<string, CacheEntry>;
cacheManager!: CacheManager;
jobs!: GenerationJobManager;
_scrollDispose: (() => void) | null = null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null;
_cacheSaveTimer: ReturnType<typeof setTimeout> | null = null;
_cacheDirty = false;
get cache(): Record<string, CacheEntry> {
return this.cacheManager.cache;
}
t(key: string, vars?: Record<string, string | number>) {
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);

157
src/cache-manager.ts Normal file
View file

@ -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<string, CacheEntry> = {};
private _timer: ReturnType<typeof setTimeout> | 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<void> {
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<Record<string, CacheEntry>> {
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<void> {
await this.ensureDir();
await this.adapter.write(this.filePath(), serializeCacheFile(this.cache));
}
async load(): Promise<void> {
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<string[]> {
const removed = this.prune();
if (removed.length > 0) await this.save();
return removed;
}
async save(): Promise<void> {
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<void> {
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<CacheEntry | null> {
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<void> {
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<boolean> {
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<void> {
if (this.cache[filePath]) {
delete this.cache[filePath];
await this.save();
}
}
async clear(): Promise<void> {
this.cache = {};
await this.save();
}
}