diff --git a/package-lock.json b/package-lock.json index 451bd35..646fa8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "moment": "^2.30.1", + "p-limit": "^7.3.0", "pdfmake": "^0.3.7", "uuid": "^11.0.5", "zod": "^4.3.6" @@ -2210,6 +2211,21 @@ "@oxlint-tsgolint/win32-x64": "0.17.4" } }, + "node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2736,6 +2752,18 @@ "node": ">=12.0.0" } }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 164f85e..94e4c38 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "moment": "^2.30.1", + "p-limit": "^7.3.0", "pdfmake": "^0.3.7", "uuid": "^11.0.5", "zod": "^4.3.6" diff --git a/src/main.ts b/src/main.ts index 74378bb..a72aa2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,7 @@ import { load, replaceTimekeepCodeblock, extractTimekeepCodeblocks } from "@/tim import { stopAllTimekeeps } from "./commands/stopAllTimekeeps"; import { stopFileTimekeeps } from "./commands/stopFileTimekeeps"; import { CustomOutputFormat } from "./output"; +import { TimekeepRegistry } from "./service/registry"; import { Timekeep, TimeEntry } from "./timekeep/schema"; import { TimekeepLocatorModal } from "./views/timekeep-locator-modal"; import { TimekeepMarkdownView } from "./views/timekeep-markdown-view"; @@ -48,6 +49,9 @@ export default class TimekeepPlugin extends Plugin { stopAllTimekeeps: (vault: Vault, currentTime: Moment) => Promise; stopFileTimekeeps: (vault: Vault, file: TFile, currentTime: Moment) => Promise; + /** Registry of all timekeeps within the vault */ + registry: TimekeepRegistry | null = null; + constructor(app: ObsidianApp, manifest: PluginManifest) { super(app, manifest); @@ -90,6 +94,19 @@ export default class TimekeepPlugin extends Plugin { this.addSettingTab(new TimekeepSettingsTab(this.app, this)); + this.app.workspace.onLayoutReady(() => { + const settings = this.settingsStore.getState(); + + if (!settings.registryEnabled) { + return; + } + + // Initialize the registry (After the layout is ready and the vault is loaded) + this.registry = new TimekeepRegistry(this.app.vault); + this.registry.concurrencyLimit = settings.registryConcurrencyLimit; + this.registry.onload(); + }); + this.registerMarkdownCodeBlockProcessor( "timekeep", (source: string, el: HTMLElement, context: MarkdownPostProcessorContext) => { diff --git a/src/service/registry.ts b/src/service/registry.ts new file mode 100644 index 0000000..2b8d431 --- /dev/null +++ b/src/service/registry.ts @@ -0,0 +1,194 @@ +import { EventRef, TAbstractFile, TFile, Vault } from "obsidian"; +import { limitFunction } from "p-limit"; + +import { createStore, Store } from "@/store"; +import { + extractTimekeepCodeblocksWithPosition, + type TimekeepWithPosition, +} from "@/timekeep/parser"; + +type TimekeepRegistryEntry = { + file: TFile; + timekeeps: TimekeepWithPosition[]; +}; + +/** + * Obsidian vault registry of all timekeep instances + */ +export class TimekeepRegistry { + /** Vault that the registry is operating on */ + vault: Vault; + + /** Store for entries within the registry */ + entries: Store; + + /** Tracked events */ + events: EventRef[] = []; + + /** Concurrency limit for registry indexing */ + concurrencyLimit: number = 10; + + constructor(vault: Vault) { + this.vault = vault; + this.entries = createStore([]); + } + + onload() { + // Attach vault events + const createEvent = this.vault.on("create", this.onFileCreated.bind(this)); + const modifyEvent = this.vault.on("modify", this.onFileModified.bind(this)); + const deleteEvent = this.vault.on("delete", this.onFileRemoved.bind(this)); + this.events.push(createEvent, modifyEvent, deleteEvent); + + // Load the registry from the vault + void this.loadFromVault() + // + .catch((error) => { + console.error("failed to load vault timekeep data", error); + }); + } + + /** + * Handle the creation of new files with the vault, indexing them + * into the timekeep registry + * + * @param file The file that was created + */ + onFileCreated(file: TAbstractFile) { + if (!(file instanceof TFile)) { + return; + } + + void this.updateFromFile(file).catch(() => + console.error("failed to update timekeep changes within file") + ); + } + + /** + * Handle a file being modified within the vault, reloads + * the change and indexes it again + * + * @param file The modified file + */ + onFileModified(file: TAbstractFile) { + if (!(file instanceof TFile)) { + return; + } + + void this.updateFromFile(file).catch(() => + console.error("failed to update timekeep changes within file") + ); + } + + /** + * Handles a file being removed from the vault removes + * the file if registered + * + * @param file The removed file + */ + onFileRemoved(file: TAbstractFile) { + if (!(file instanceof TFile)) { + return; + } + + this.entries.setState((entries) => { + const newEntries = entries.filter((entry) => { + console.log(file, entry.file, file === entry.file); + return entry.file !== file; + }); + return newEntries; + }); + } + + /** + * Load the registry from the current vault + */ + async loadFromVault() { + const entries = await TimekeepRegistry.getTimekeepsWithinVault( + this.vault, + true, + this.concurrencyLimit + ); + this.entries.setState(entries); + } + + /** + * Update a file within the vault loading its timekeeps then + * replacing them within the registry + * + * @param file + */ + async updateFromFile(file: TFile) { + const timekeeps = await TimekeepRegistry.getTimekeepsWithinFile(this.vault, file, true); + + this.entries.setState((entries) => { + const filteredEntries: TimekeepRegistryEntry[] = entries.filter( + (entry) => entry.file !== file + ); + const newEntry: TimekeepRegistryEntry = { + file, + timekeeps, + }; + + const newEntries = [...filteredEntries, newEntry]; + return newEntries; + }); + } + + /** + * Collect all timekeep's within the provided vault + * + * @param vault The vault to search within + * @param cached Whether to perform a cached read when checking + * @param concurrencyLimit Maximum number of files to search at once + * @returns The collection of timekeeps with their positions in each file + */ + static async getTimekeepsWithinVault( + vault: Vault, + cached: boolean = true, + concurrencyLimit: number = 15 + ): Promise { + const markdownFiles = vault.getMarkdownFiles(); + + // Concurrency limited parallel file processing + const processFile = limitFunction( + async (file: TFile): Promise => { + const timekeeps = await TimekeepRegistry.getTimekeepsWithinFile( + vault, + file, + cached + ); + + return { file, timekeeps }; + }, + { concurrency: concurrencyLimit } + ); + + const promises = markdownFiles.map(processFile); + return Promise.all(promises); + } + + /** + * Collect all timekeep's within the provided file in the + * provided vault + * + * @param vault The vault to search within + * @param file The file to search within + * @param cached Whether to perform a cached read + * @returns The collection of timekeeps with their positions in each file + */ + static async getTimekeepsWithinFile( + vault: Vault, + file: TFile, + cached: boolean = true + ): Promise { + let content: string; + if (cached) { + content = await vault.cachedRead(file); + } else { + content = await vault.read(file); + } + + return extractTimekeepCodeblocksWithPosition(content); + } +} diff --git a/src/settings-tab.ts b/src/settings-tab.ts index bf4c4d6..c29d4eb 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -375,5 +375,49 @@ export class TimekeepSettingsTab extends PluginSettingTab { })); }); }); + + // Registry settings section + new Setting(this.containerEl) + .setName("Registry") + .setDesc( + "Timekeep uses an internal registry to track timekeep instances within your vault for functionality like autocomplete" + ) + .setHeading(); + + new Setting(this.containerEl) + .setName("Enabled") + .setDesc( + "Whether to enable the registry, this can be disabled to reduce memory usage if you don't need the features that depend on it." + ) + .addToggle((t) => { + t.setValue(settings.registryEnabled); + t.onChange((v) => { + this.settingsStore.setState((currentValue) => ({ + ...currentValue, + registryEnabled: v, + })); + }); + }); + + new Setting(this.containerEl) + .setName("Index Concurrency") + .setDesc( + "Maximum files to read concurrently on initialization (decrease this if you find you are lagging when opening your vault because of timekeep)" + ) + + .addText((t) => { + t.setValue(String(settings.registryConcurrencyLimit)); + t.onChange((v) => { + const value = Number(v); + + this.settingsStore.setState((currentValue) => ({ + ...currentValue, + registryConcurrencyLimit: + Number.isFinite(value) && Number.isSafeInteger(value) + ? value + : defaultSettings.registryConcurrencyLimit, + })); + }); + }); } } diff --git a/src/settings.ts b/src/settings.ts index 4665155..9f01e98 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -69,6 +69,9 @@ export interface TimekeepSettings { sortOrder: SortOrder; unstartedOrder: UnstartedOrder; + + registryEnabled: boolean; + registryConcurrencyLimit: number; } export const defaultSettings: TimekeepSettings = { @@ -92,6 +95,9 @@ export const defaultSettings: TimekeepSettings = { sortOrder: SortOrder.INSERTION, unstartedOrder: UnstartedOrder.LAST, + + registryEnabled: true, + registryConcurrencyLimit: 15, }; export function legacySettingsCompatibility(settings: TimekeepSettings): void {