feat(library): controller, settings, commands, scheduler
This commit is contained in:
parent
278f009a9b
commit
2cce78d664
4 changed files with 547 additions and 3 deletions
211
packages/obsidian/src/library/LibraryController.ts
Normal file
211
packages/obsidian/src/library/LibraryController.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { Notice, TFile, TFolder } from 'obsidian';
|
||||||
|
import { bookSpec } from 'packages/obsidian/src/library/book';
|
||||||
|
import { comicSpec } from 'packages/obsidian/src/library/comic';
|
||||||
|
import { gameSpec } from 'packages/obsidian/src/library/game';
|
||||||
|
import { libraryFolderResolve, libraryFolderSync, type LibraryEngineDeps, type LibraryReport, type LibraryResolveReport } from 'packages/obsidian/src/library/LibraryEngine';
|
||||||
|
import { mangaSpec } from 'packages/obsidian/src/library/manga';
|
||||||
|
import type { HttpJsonFn, HttpTextFn, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types';
|
||||||
|
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||||
|
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||||
|
import { TmdbRateLimitError } from 'packages/obsidian/src/watchlist/SyncEngine';
|
||||||
|
import { shouldNotifySync } from 'packages/obsidian/src/watchlist/WatchlistController';
|
||||||
|
|
||||||
|
/** Type <-> command-slug map, used by main.ts to register per-type commands and by the controller to iterate specs. */
|
||||||
|
export const LIBRARY_TYPES: readonly { spec: MediaTypeSpec; slug: string }[] = [
|
||||||
|
{ spec: mangaSpec, slug: 'mangas' },
|
||||||
|
{ spec: bookSpec, slug: 'books' },
|
||||||
|
{ spec: gameSpec, slug: 'games' },
|
||||||
|
{ spec: comicSpec, slug: 'comics' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Specs whose enrichment needs a secretStorage-held API key (jikan/mangadex/openlibrary/rss/steam are keyless). */
|
||||||
|
const KEY_REQUIREMENT: Partial<Record<MediaTypeSpec['typeName'], 'rawg' | 'comicvine'>> = {
|
||||||
|
game: 'rawg',
|
||||||
|
comic: 'comicvine',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_LABEL: Record<'rawg' | 'comicvine', string> = {
|
||||||
|
rawg: 'RAWG',
|
||||||
|
comicvine: 'Comic Vine',
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyReport(): LibraryReport {
|
||||||
|
return { scanned: 0, synced: 0, written: 0, skippedNoId: 0, skippedStatic: 0, skippedNoData: 0, flipped: [], errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyResolveReport(): LibraryResolveReport {
|
||||||
|
return { resolved: [], ambiguous: [], errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LibraryController {
|
||||||
|
private syncing = false;
|
||||||
|
private specs: MediaTypeSpec[] = LIBRARY_TYPES.map(t => t.spec);
|
||||||
|
|
||||||
|
constructor(private plugin: MediaDbPlugin) {}
|
||||||
|
|
||||||
|
private notify(msg: string): void {
|
||||||
|
new Notice(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKey(name: 'rawg' | 'comicvine'): string {
|
||||||
|
const keyId = name === 'rawg' ? this.plugin.settings.RAWGAPIKeyId : this.plugin.settings.ComicVineKeyId;
|
||||||
|
const key = keyId ? this.plugin.app.secretStorage.getSecret(keyId) : null;
|
||||||
|
return key ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private folderFor(spec: MediaTypeSpec): string {
|
||||||
|
return (this.plugin.settings as unknown as Record<string, string>)[spec.folderSettingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
private enabledFor(spec: MediaTypeSpec): boolean {
|
||||||
|
return (this.plugin.settings as unknown as Record<string, boolean>)[spec.enabledSettingKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicated (not extracted) from WatchlistController.makeHttp -- watchlist code stays untouched per plan.
|
||||||
|
// Kept minimal + generic (no TMDB-specific wording) since it's shared across all library APIs.
|
||||||
|
private makeHttp(): HttpJsonFn {
|
||||||
|
return async (url: string, headers: Record<string, string>): Promise<any> => {
|
||||||
|
const res = await obsidianFetch(new Request(url, { headers }));
|
||||||
|
if (res.status === 429) {
|
||||||
|
const err = new TmdbRateLimitError(`Library API 429 for ${url}`);
|
||||||
|
const ra = Number(res.headers.get('retry-after'));
|
||||||
|
err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`);
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeHttpText(): HttpTextFn {
|
||||||
|
return async (url: string, headers: Record<string, string>): Promise<string> => {
|
||||||
|
const res = await obsidianFetch(new Request(url, { headers }));
|
||||||
|
if (res.status === 429) {
|
||||||
|
const err = new TmdbRateLimitError(`Library API 429 for ${url}`);
|
||||||
|
const ra = Number(res.headers.get('retry-after'));
|
||||||
|
err.retryAfterMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 2000;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (res.status !== 200) throw new Error(`Library API ${res.status} for ${url}`);
|
||||||
|
return await res.text();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeSpecDeps(): SpecDeps {
|
||||||
|
return {
|
||||||
|
http: this.makeHttp(),
|
||||||
|
httpText: this.makeHttpText(),
|
||||||
|
getKey: name => this.getKey(name),
|
||||||
|
log: msg => console.log(`[media-db-library] ${msg}`),
|
||||||
|
notify: msg => this.notify(msg),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeDeps(spec: MediaTypeSpec): LibraryEngineDeps {
|
||||||
|
const { app } = this.plugin;
|
||||||
|
const folderPath = this.folderFor(spec);
|
||||||
|
const folder = app.vault.getAbstractFileByPath(folderPath);
|
||||||
|
return {
|
||||||
|
listNotes: async () => {
|
||||||
|
if (!(folder instanceof TFolder)) throw new Error(`Library folder not found: ${folderPath}`);
|
||||||
|
const files = folder.children.filter((f): f is TFile => f instanceof TFile && f.extension === 'md' && !f.name.startsWith('_'));
|
||||||
|
return files.map(f => ({ path: f.path }));
|
||||||
|
},
|
||||||
|
readNote: async path => {
|
||||||
|
const f = app.vault.getAbstractFileByPath(path);
|
||||||
|
if (!(f instanceof TFile)) throw new Error(`Library note not found: ${path}`);
|
||||||
|
return await app.vault.read(f);
|
||||||
|
},
|
||||||
|
writeNote: async (path, content) => {
|
||||||
|
const f = app.vault.getAbstractFileByPath(path);
|
||||||
|
if (f instanceof TFile) await app.vault.modify(f, content);
|
||||||
|
},
|
||||||
|
sleep: ms => new Promise(r => setTimeout(r, ms)),
|
||||||
|
log: msg => console.log(`[media-db-library] ${msg}`),
|
||||||
|
specDeps: this.makeSpecDeps(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-note key skipping already happens inside the spec (log+skip); this only surfaces one Notice per sync run naming the affected type. */
|
||||||
|
private warnMissingKey(spec: MediaTypeSpec): void {
|
||||||
|
const req = KEY_REQUIREMENT[spec.typeName];
|
||||||
|
if (!req || this.getKey(req)) return;
|
||||||
|
this.notify(`Library sync: ${KEY_LABEL[req]} API key not configured — ${spec.typeName} enrichment skipped (Media DB Sync settings).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSync(spec: MediaTypeSpec, full: boolean, dryRun: boolean, quiet: boolean): Promise<LibraryReport> {
|
||||||
|
this.warnMissingKey(spec);
|
||||||
|
const deps = this.makeDeps(spec);
|
||||||
|
const report = await libraryFolderSync(spec, deps, { full, dryRun });
|
||||||
|
const mode = dryRun ? 'DRY-RUN ' : '';
|
||||||
|
if (shouldNotifySync(quiet, report.written, report.errors.length)) {
|
||||||
|
this.notify(
|
||||||
|
`Library ${mode}sync (${spec.typeName}): ${report.synced} checked, ${report.written} updated, ${report.flipped.length} flipped` +
|
||||||
|
(report.errors.length ? `, ${report.errors.length} errors (see console)` : ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncType(spec: MediaTypeSpec, full: boolean, dryRun = false, quiet = false): Promise<LibraryReport> {
|
||||||
|
if (this.syncing) {
|
||||||
|
this.notify('Library sync already running');
|
||||||
|
return emptyReport();
|
||||||
|
}
|
||||||
|
this.syncing = true;
|
||||||
|
try {
|
||||||
|
return await this.runSync(spec, full, dryRun, quiet);
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveType(spec: MediaTypeSpec, dryRun = false): Promise<LibraryResolveReport> {
|
||||||
|
if (this.syncing) {
|
||||||
|
this.notify('Library sync already running');
|
||||||
|
return emptyResolveReport();
|
||||||
|
}
|
||||||
|
this.syncing = true;
|
||||||
|
try {
|
||||||
|
const deps = this.makeDeps(spec);
|
||||||
|
const report = await libraryFolderResolve(spec, deps, { dryRun });
|
||||||
|
const mode = dryRun ? 'DRY-RUN ' : '';
|
||||||
|
this.notify(
|
||||||
|
`Library ${mode}resolve (${spec.typeName}): ${report.resolved.length} resolved, ${report.ambiguous.length} ambiguous/no match` +
|
||||||
|
(report.errors.length ? `, ${report.errors.length} errors (see console)` : ''),
|
||||||
|
);
|
||||||
|
return report;
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAll(full: boolean, quiet = false): Promise<void> {
|
||||||
|
if (this.syncing) {
|
||||||
|
this.notify('Library sync already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.syncing = true;
|
||||||
|
try {
|
||||||
|
for (const spec of this.specs) {
|
||||||
|
if (!this.enabledFor(spec)) continue;
|
||||||
|
await this.runSync(spec, full, false, quiet);
|
||||||
|
}
|
||||||
|
if (!full) {
|
||||||
|
this.plugin.settings.libraryLastSync = Date.now();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async maybeCatchUp(): Promise<void> {
|
||||||
|
const s = this.plugin.settings;
|
||||||
|
if (!this.specs.some(spec => this.enabledFor(spec))) return;
|
||||||
|
const due = s.libraryLastSync + s.watchlistSyncIntervalHours * 3600_000;
|
||||||
|
if (Date.now() >= due) {
|
||||||
|
await this.syncAll(false, true).catch(e => console.error('[media-db-library] scheduled sync failed', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI';
|
||||||
import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI';
|
import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI';
|
||||||
import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI';
|
import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI';
|
||||||
import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI';
|
import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI';
|
||||||
|
import { LibraryController, LIBRARY_TYPES } from 'packages/obsidian/src/library/LibraryController';
|
||||||
import type { LegacyApiKeyEntry } from 'packages/obsidian/src/modals/LegacyApiKeysModal';
|
import type { LegacyApiKeyEntry } from 'packages/obsidian/src/modals/LegacyApiKeysModal';
|
||||||
import { LegacyApiKeysModal } from 'packages/obsidian/src/modals/LegacyApiKeysModal';
|
import { LegacyApiKeysModal } from 'packages/obsidian/src/modals/LegacyApiKeysModal';
|
||||||
import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper';
|
import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper';
|
||||||
|
|
@ -46,6 +47,7 @@ export default class MediaDbPlugin extends Plugin {
|
||||||
dateFormatter!: DateFormatter;
|
dateFormatter!: DateFormatter;
|
||||||
errorReporter!: ErrorReporter;
|
errorReporter!: ErrorReporter;
|
||||||
watchlist!: WatchlistController;
|
watchlist!: WatchlistController;
|
||||||
|
library!: LibraryController;
|
||||||
|
|
||||||
async onload(): Promise<void> {
|
async onload(): Promise<void> {
|
||||||
this.mediaTypeManager = new MediaTypeManager();
|
this.mediaTypeManager = new MediaTypeManager();
|
||||||
|
|
@ -64,10 +66,13 @@ export default class MediaDbPlugin extends Plugin {
|
||||||
this.registerCommands();
|
this.registerCommands();
|
||||||
|
|
||||||
this.watchlist = new WatchlistController(this);
|
this.watchlist = new WatchlistController(this);
|
||||||
// catch-up shortly after startup (let vault index settle), then hourly due-check
|
this.library = new LibraryController(this);
|
||||||
const catchUpTimeout = window.setTimeout(() => void this.watchlist.maybeCatchUp(), 30_000);
|
// catch-up shortly after startup (let vault index settle), then hourly due-check.
|
||||||
|
// library catch-up runs after watchlist's, sharing the same timers (no new timers).
|
||||||
|
const catchUp = () => void this.watchlist.maybeCatchUp().then(() => this.library.maybeCatchUp());
|
||||||
|
const catchUpTimeout = window.setTimeout(catchUp, 30_000);
|
||||||
this.register(() => window.clearTimeout(catchUpTimeout));
|
this.register(() => window.clearTimeout(catchUpTimeout));
|
||||||
this.registerInterval(window.setInterval(() => void this.watchlist.maybeCatchUp(), 3600_000));
|
this.registerInterval(window.setInterval(catchUp, 3600_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload(): void {}
|
onunload(): void {}
|
||||||
|
|
@ -197,6 +202,29 @@ export default class MediaDbPlugin extends Plugin {
|
||||||
name: 'Watchlist: resolve missing TMDB ids',
|
name: 'Watchlist: resolve missing TMDB ids',
|
||||||
callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
callback: () => void this.watchlist.resolveMissingIds().catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const { spec, slug } of LIBRARY_TYPES) {
|
||||||
|
this.addCommand({
|
||||||
|
id: `library-sync-${slug}`,
|
||||||
|
name: `Library: sync ${slug} now`,
|
||||||
|
callback: () => void this.library.syncType(spec, false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
||||||
|
});
|
||||||
|
this.addCommand({
|
||||||
|
id: `library-resolve-${slug}`,
|
||||||
|
name: `Library: resolve ${slug} ids`,
|
||||||
|
callback: () => void this.library.resolveType(spec).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
||||||
|
});
|
||||||
|
this.addCommand({
|
||||||
|
id: `library-dry-run-${slug}`,
|
||||||
|
name: `Library: dry-run ${slug} full sync`,
|
||||||
|
callback: () => void this.library.syncType(spec, true, true).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.addCommand({
|
||||||
|
id: 'library-sync-all',
|
||||||
|
name: 'Library: sync all',
|
||||||
|
callback: () => void this.library.syncAll(false).catch((e: unknown) => new Notice(e instanceof Error ? e.message : String(e))),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLegacyApiKeyEntries(diskSettings: Record<string, unknown>): LegacyApiKeyEntry[] {
|
private getLegacyApiKeyEntries(diskSettings: Record<string, unknown>): LegacyApiKeyEntry[] {
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,16 @@ export interface MediaDbPluginSettings {
|
||||||
watchlistSyncIntervalHours: number;
|
watchlistSyncIntervalHours: number;
|
||||||
watchlistLastSync: number;
|
watchlistLastSync: number;
|
||||||
|
|
||||||
|
libraryMangaEnabled: boolean;
|
||||||
|
libraryMangaFolder: string;
|
||||||
|
libraryBookEnabled: boolean;
|
||||||
|
libraryBookFolder: string;
|
||||||
|
libraryGameEnabled: boolean;
|
||||||
|
libraryGameFolder: string;
|
||||||
|
libraryComicEnabled: boolean;
|
||||||
|
libraryComicFolder: string;
|
||||||
|
libraryLastSync: number;
|
||||||
|
|
||||||
// DEPRECATED: Use propertyMappingModels instead
|
// DEPRECATED: Use propertyMappingModels instead
|
||||||
moviePropertyConversionRules: string;
|
moviePropertyConversionRules: string;
|
||||||
seriesPropertyConversionRules: string;
|
seriesPropertyConversionRules: string;
|
||||||
|
|
@ -388,6 +398,16 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = {
|
||||||
watchlistSyncIntervalHours: 24,
|
watchlistSyncIntervalHours: 24,
|
||||||
watchlistLastSync: 0,
|
watchlistLastSync: 0,
|
||||||
|
|
||||||
|
libraryMangaEnabled: false,
|
||||||
|
libraryMangaFolder: 'Mangas',
|
||||||
|
libraryBookEnabled: false,
|
||||||
|
libraryBookFolder: 'Books',
|
||||||
|
libraryGameEnabled: false,
|
||||||
|
libraryGameFolder: 'Games',
|
||||||
|
libraryComicEnabled: false,
|
||||||
|
libraryComicFolder: 'Comics',
|
||||||
|
libraryLastSync: 0,
|
||||||
|
|
||||||
// DEPRECATED
|
// DEPRECATED
|
||||||
moviePropertyConversionRules: '',
|
moviePropertyConversionRules: '',
|
||||||
seriesPropertyConversionRules: '',
|
seriesPropertyConversionRules: '',
|
||||||
|
|
@ -791,6 +811,54 @@ export class MediaDbSettingTab extends PluginSettingTab {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MARK: Library sync
|
||||||
|
const libraryGroup = new SettingGroup(containerEl);
|
||||||
|
libraryGroup.setHeading('Library sync');
|
||||||
|
|
||||||
|
const libraryTypeSettings: { label: string; enabledKey: keyof MediaDbPluginSettings; folderKey: keyof MediaDbPluginSettings; desc: string }[] = [
|
||||||
|
{ label: 'Manga', enabledKey: 'libraryMangaEnabled', folderKey: 'libraryMangaFolder', desc: 'Jikan (MyAnimeList) + MangaDex/RSS chapter tracking.' },
|
||||||
|
{ label: 'Book', enabledKey: 'libraryBookEnabled', folderKey: 'libraryBookFolder', desc: 'Open Library enrichment.' },
|
||||||
|
{ label: 'Game', enabledKey: 'libraryGameEnabled', folderKey: 'libraryGameFolder', desc: 'Steam + RAWG enrichment. Uses the RAWG API key configured above.' },
|
||||||
|
{ label: 'Comic', enabledKey: 'libraryComicEnabled', folderKey: 'libraryComicFolder', desc: 'Comic Vine enrichment. Uses the Comic Vine API key configured above.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { label, enabledKey, folderKey, desc } of libraryTypeSettings) {
|
||||||
|
libraryGroup.addSetting(
|
||||||
|
setting =>
|
||||||
|
void setting
|
||||||
|
.setName(`Enable ${label.toLowerCase()} library sync`)
|
||||||
|
.setDesc(`Periodically sync ${label.toLowerCase()} notes against their source APIs. ${desc}`)
|
||||||
|
.addToggle(cb => {
|
||||||
|
cb.setValue(this.plugin.settings[enabledKey] as boolean).onChange(data => {
|
||||||
|
(this.plugin.settings[enabledKey] as boolean) = data;
|
||||||
|
void this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
libraryGroup.addSetting(
|
||||||
|
setting =>
|
||||||
|
void setting
|
||||||
|
.setName(`${label} folder`)
|
||||||
|
.setDesc(`Folder containing ${label.toLowerCase()} notes.`)
|
||||||
|
.addSearch(cb => {
|
||||||
|
const suggester = new FolderSuggest(this.app, cb.inputEl);
|
||||||
|
suggester.onSelect(folder => {
|
||||||
|
cb.setValue(folder.path);
|
||||||
|
(this.plugin.settings[folderKey] as string) = folder.path;
|
||||||
|
void this.plugin.saveSettings();
|
||||||
|
suggester.close();
|
||||||
|
});
|
||||||
|
cb.setPlaceholder(DEFAULT_SETTINGS[folderKey] as string)
|
||||||
|
.setValue(this.plugin.settings[folderKey] as string)
|
||||||
|
.onChange(data => {
|
||||||
|
(this.plugin.settings[folderKey] as string) = data;
|
||||||
|
void this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Media type settings
|
// MARK: Media type settings
|
||||||
|
|
||||||
// Create a map to store APIs for each media type
|
// Create a map to store APIs for each media type
|
||||||
|
|
|
||||||
237
tests/library-controller.test.ts
Normal file
237
tests/library-controller.test.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { bookSpec } from 'packages/obsidian/src/library/book';
|
||||||
|
import { gameSpec } from 'packages/obsidian/src/library/game';
|
||||||
|
import { LibraryController } from 'packages/obsidian/src/library/LibraryController';
|
||||||
|
import type { LibraryEngineDeps } from 'packages/obsidian/src/library/LibraryEngine';
|
||||||
|
import { mangaSpec } from 'packages/obsidian/src/library/manga';
|
||||||
|
|
||||||
|
function fakePlugin(
|
||||||
|
overrides: Partial<{
|
||||||
|
mangaEnabled: boolean;
|
||||||
|
bookEnabled: boolean;
|
||||||
|
gameEnabled: boolean;
|
||||||
|
comicEnabled: boolean;
|
||||||
|
last: number;
|
||||||
|
hours: number;
|
||||||
|
rawgKeyId: string;
|
||||||
|
comicvineKeyId: string;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const secrets: Record<string, string> = {};
|
||||||
|
if (overrides.rawgKeyId) secrets[overrides.rawgKeyId] = 'rawg-secret';
|
||||||
|
if (overrides.comicvineKeyId) secrets[overrides.comicvineKeyId] = 'cv-secret';
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
libraryMangaEnabled: overrides.mangaEnabled ?? true,
|
||||||
|
libraryMangaFolder: 'Mangas',
|
||||||
|
libraryBookEnabled: overrides.bookEnabled ?? true,
|
||||||
|
libraryBookFolder: 'Books',
|
||||||
|
libraryGameEnabled: overrides.gameEnabled ?? true,
|
||||||
|
libraryGameFolder: 'Games',
|
||||||
|
libraryComicEnabled: overrides.comicEnabled ?? true,
|
||||||
|
libraryComicFolder: 'Comics',
|
||||||
|
libraryLastSync: overrides.last ?? 0,
|
||||||
|
watchlistSyncIntervalHours: overrides.hours ?? 24,
|
||||||
|
RAWGAPIKeyId: overrides.rawgKeyId ?? '',
|
||||||
|
ComicVineKeyId: overrides.comicvineKeyId ?? '',
|
||||||
|
},
|
||||||
|
saveSettings: async () => {},
|
||||||
|
app: {
|
||||||
|
secretStorage: { getSecret: (id: string) => secrets[id] ?? null },
|
||||||
|
vault: {},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('maybeCatchUp', () => {
|
||||||
|
test('overdue → syncs', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin({ last: 0 }));
|
||||||
|
let called = false;
|
||||||
|
(c as any).syncAll = async () => {
|
||||||
|
called = true;
|
||||||
|
};
|
||||||
|
await c.maybeCatchUp();
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recent sync → no call', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin({ last: Date.now() }));
|
||||||
|
let called = false;
|
||||||
|
(c as any).syncAll = async () => {
|
||||||
|
called = true;
|
||||||
|
};
|
||||||
|
await c.maybeCatchUp();
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all types disabled → no call', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin({ mangaEnabled: false, bookEnabled: false, gameEnabled: false, comicEnabled: false, last: 0 }));
|
||||||
|
let called = false;
|
||||||
|
(c as any).syncAll = async () => {
|
||||||
|
called = true;
|
||||||
|
};
|
||||||
|
await c.maybeCatchUp();
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeSpecDeps() {
|
||||||
|
return { http: async () => ({}), httpText: async () => '', getKey: () => '', log: () => {}, notify: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferredDeps(): { deps: LibraryEngineDeps; listNotesCalls: () => number; release: () => void } {
|
||||||
|
let listNotesCalls = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>(resolve => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const deps: LibraryEngineDeps = {
|
||||||
|
listNotes: async () => {
|
||||||
|
listNotesCalls++;
|
||||||
|
await gate;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
readNote: async () => '',
|
||||||
|
writeNote: async () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
specDeps: fakeSpecDeps(),
|
||||||
|
};
|
||||||
|
return { deps, listNotesCalls: () => listNotesCalls, release };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('concurrency guard', () => {
|
||||||
|
test('overlapping syncType calls: second short-circuits while first is in flight', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin());
|
||||||
|
const { deps, listNotesCalls, release } = deferredDeps();
|
||||||
|
(c as any).makeDeps = () => deps;
|
||||||
|
|
||||||
|
const first = c.syncType(mangaSpec, false);
|
||||||
|
const second = await c.syncType(mangaSpec, false);
|
||||||
|
|
||||||
|
expect(listNotesCalls()).toBe(1);
|
||||||
|
expect(second.scanned).toBe(0);
|
||||||
|
expect(second.written).toBe(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
const firstResult = await first;
|
||||||
|
expect(firstResult.scanned).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncType in flight blocks resolveType (shared guard)', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin());
|
||||||
|
const { deps, release } = deferredDeps();
|
||||||
|
(c as any).makeDeps = () => deps;
|
||||||
|
|
||||||
|
const first = c.syncType(mangaSpec, false);
|
||||||
|
const resolveResult = await c.resolveType(bookSpec);
|
||||||
|
|
||||||
|
expect(resolveResult.resolved.length).toBe(0);
|
||||||
|
expect(resolveResult.ambiguous.length).toBe(0);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncType in flight blocks syncAll (shared guard)', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin());
|
||||||
|
const { deps, release } = deferredDeps();
|
||||||
|
(c as any).makeDeps = () => deps;
|
||||||
|
|
||||||
|
const first = c.syncType(mangaSpec, false);
|
||||||
|
await c.syncAll(false, true); // should short-circuit, not throw
|
||||||
|
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flag resets after completion → next call runs normally', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin());
|
||||||
|
let listNotesCalls = 0;
|
||||||
|
(c as any).makeDeps = () => ({
|
||||||
|
listNotes: async () => {
|
||||||
|
listNotesCalls++;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
readNote: async () => '',
|
||||||
|
writeNote: async () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
specDeps: fakeSpecDeps(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.syncType(mangaSpec, false);
|
||||||
|
await c.syncType(mangaSpec, false);
|
||||||
|
|
||||||
|
expect(listNotesCalls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missing API key handling', () => {
|
||||||
|
test('missing RAWG + Comic Vine keys → one notice each for game/comic, manga/book unaffected', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin()); // no rawgKeyId/comicvineKeyId configured
|
||||||
|
const notices: string[] = [];
|
||||||
|
(c as any).notify = (msg: string) => notices.push(msg);
|
||||||
|
const calledTypes: string[] = [];
|
||||||
|
(c as any).makeDeps = (spec: { typeName: string }) => {
|
||||||
|
calledTypes.push(spec.typeName);
|
||||||
|
return {
|
||||||
|
listNotes: async () => [],
|
||||||
|
readNote: async () => '',
|
||||||
|
writeNote: async () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
specDeps: fakeSpecDeps(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await c.syncAll(false, true);
|
||||||
|
|
||||||
|
expect(calledTypes.sort()).toEqual(['book', 'comic', 'game', 'manga']);
|
||||||
|
const keyNotices = notices.filter(m => m.includes('API key not configured'));
|
||||||
|
expect(keyNotices.length).toBe(2);
|
||||||
|
expect(keyNotices.some(m => m.includes('RAWG') && m.includes('game'))).toBe(true);
|
||||||
|
expect(keyNotices.some(m => m.includes('Comic Vine') && m.includes('comic'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keys present → no missing-key notices', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin({ rawgKeyId: 'rid', comicvineKeyId: 'cvid' }));
|
||||||
|
const notices: string[] = [];
|
||||||
|
(c as any).notify = (msg: string) => notices.push(msg);
|
||||||
|
(c as any).makeDeps = () => ({
|
||||||
|
listNotes: async () => [],
|
||||||
|
readNote: async () => '',
|
||||||
|
writeNote: async () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
specDeps: fakeSpecDeps(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.syncAll(false, true);
|
||||||
|
|
||||||
|
expect(notices.filter(m => m.includes('API key not configured')).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-type sync (game) with missing RAWG key still notifies once and runs', async () => {
|
||||||
|
const c = new LibraryController(fakePlugin({ rawgKeyId: '' }));
|
||||||
|
const notices: string[] = [];
|
||||||
|
(c as any).notify = (msg: string) => notices.push(msg);
|
||||||
|
let ran = false;
|
||||||
|
(c as any).makeDeps = () => {
|
||||||
|
ran = true;
|
||||||
|
return {
|
||||||
|
listNotes: async () => [],
|
||||||
|
readNote: async () => '',
|
||||||
|
writeNote: async () => {},
|
||||||
|
sleep: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
specDeps: fakeSpecDeps(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await c.syncType(gameSpec, false);
|
||||||
|
|
||||||
|
expect(ran).toBe(true);
|
||||||
|
expect(notices.filter(m => m.includes('API key not configured')).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue