From ee94f6b13cb40842ad586318fda54f359bc5caa3 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Fri, 21 Nov 2025 12:51:08 +0100 Subject: [PATCH] feat: extension to non-markdown files --- README.md | 2 + components/StatusDashboard/useVaultStats.tsx | 61 ++-- core/eventBus.ts | 6 +- core/noteStatusService.ts | 322 ++++++++---------- core/statusStoreManager.ts | 37 ++ core/statusStores/frontmatterStatusStore.ts | 80 +++++ core/statusStores/nonMarkdownStatusStore.ts | 141 ++++++++ core/statusStores/types.ts | 18 + .../context-menu/contextMenuIntegration.tsx | 50 ++- .../file-explorer-integration.tsx | 22 +- .../modals/statusModalIntegration.tsx | 4 +- integrations/status-bar/status-bar.tsx | 41 +-- .../toolbar/editorToolbarIntegration.tsx | 4 +- integrations/views/grouped-dashboard-view.tsx | 61 ++-- integrations/views/grouped-status-view.tsx | 65 ++-- integrations/views/status-dashboard-view.tsx | 63 ++-- main.tsx | 22 +- types/eventBus.ts | 2 +- 18 files changed, 571 insertions(+), 430 deletions(-) create mode 100644 core/statusStoreManager.ts create mode 100644 core/statusStores/frontmatterStatusStore.ts create mode 100644 core/statusStores/nonMarkdownStatusStore.ts create mode 100644 core/statusStores/types.ts diff --git a/README.md b/README.md index 697bd25..05a29be 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Works with: - **QuickAdd macros** - **Global search** +Non-Markdown files (PDF, Canvas, images, etc.) can't host YAML frontmatter. Their statuses live in a lightweight JSON file under `.obsidian/plugins/obsidian-note-status/non-markdown-statuses.json`, and every command/view in the plugin reads from that store transparently. External tools such as Dataview or Templater still only see Markdown/frontmatter data, so keep using Markdown files if you need those integrations. + ## 🛠️ API Reference For developers building integrations or contributing: diff --git a/components/StatusDashboard/useVaultStats.tsx b/components/StatusDashboard/useVaultStats.tsx index 70c49cf..22b9aaf 100644 --- a/components/StatusDashboard/useVaultStats.tsx +++ b/components/StatusDashboard/useVaultStats.tsx @@ -1,7 +1,10 @@ import { useState, useCallback } from "react"; import { TFile } from "obsidian"; import { NoteStatus } from "@/types/noteStatus"; -import { BaseNoteStatusService } from "@/core/noteStatusService"; +import { + BaseNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; import settingsService from "@/core/settingsService"; export interface VaultStats { @@ -27,7 +30,7 @@ export const useVaultStats = () => { }); const calculateVaultStats = useCallback((): VaultStats => { - const files = BaseNoteStatusService.app.vault.getMarkdownFiles(); + const files = BaseNoteStatusService.app.vault.getFiles(); const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses(); const statusMetadataKeys = [settingsService.settings.tagPrefix]; @@ -49,50 +52,26 @@ export const useVaultStats = () => { }); files.forEach((file) => { - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(file); - const frontmatter = cachedMetadata?.frontmatter; - - if (!frontmatter) return; - + const noteStatusService = new NoteStatusService(file); + noteStatusService.populateStatuses(); let hasAnyStatus = false; statusMetadataKeys.forEach((key) => { - const value = frontmatter[key]; - if (value) { - hasAnyStatus = true; - tagDistribution[key]++; + const statuses = noteStatusService.statuses[key] || []; + if (!statuses.length) return; - const statusNames = Array.isArray(value) ? value : [value]; - statusNames.forEach((statusName) => { - const statusStr = statusName.toString(); + hasAnyStatus = true; + tagDistribution[key]++; - // Determine the scoped identifier to use - let scopedIdentifier: string; - - if (statusStr.includes(":")) { - // Already scoped - scopedIdentifier = statusStr; - } else { - // Legacy status - find first template that has this status - const firstTemplateWithStatus = - availableStatuses.find( - (s) => s.name === statusStr && s.templateId, - ); - scopedIdentifier = firstTemplateWithStatus - ? `${firstTemplateWithStatus.templateId}:${statusStr}` - : statusStr; // Fallback to unscoped if no template found - } - - // Initialize status if not already present - if ( - !statusDistribution.hasOwnProperty(scopedIdentifier) - ) { - statusDistribution[scopedIdentifier] = 0; - } - statusDistribution[scopedIdentifier]++; - }); - } + statuses.forEach((status) => { + const scopedIdentifier = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + if (!statusDistribution.hasOwnProperty(scopedIdentifier)) { + statusDistribution[scopedIdentifier] = 0; + } + statusDistribution[scopedIdentifier]++; + }); }); if (hasAnyStatus) { diff --git a/core/eventBus.ts b/core/eventBus.ts index 57cdf0c..f3eaa58 100644 --- a/core/eventBus.ts +++ b/core/eventBus.ts @@ -4,7 +4,7 @@ class EventBus { private events: { [K in EventName]: Map } = { "active-file-change": new Map(), "plugin-settings-changed": new Map(), - "frontmatter-manually-changed": new Map(), + "status-changed": new Map(), "triggered-open-modal": new Map(), }; @@ -32,8 +32,8 @@ class EventBus { ...args: Parameters ): void; publish( - event: "frontmatter-manually-changed", - ...args: Parameters + event: "status-changed", + ...args: Parameters ): void; publish( event: "triggered-open-modal", diff --git a/core/noteStatusService.ts b/core/noteStatusService.ts index d76fffb..e35afa2 100644 --- a/core/noteStatusService.ts +++ b/core/noteStatusService.ts @@ -8,6 +8,8 @@ import { } from "@/types/noteStatus"; import settingsService from "@/core/settingsService"; import eventBus from "./eventBus"; +import statusStoreManager from "./statusStoreManager"; +import type { StatusStore } from "./statusStores/types"; export abstract class BaseNoteStatusService { static app: App; @@ -119,10 +121,12 @@ export abstract class BaseNoteStatusService { export class NoteStatusService extends BaseNoteStatusService { private file: TFile; + private statusStore: StatusStore; constructor(file: TFile) { super(); this.file = file; + this.statusStore = statusStoreManager.getStoreForFile(file); } private shouldStoreStatusesAsArray(): boolean { @@ -133,39 +137,13 @@ export class NoteStatusService extends BaseNoteStatusService { return mode === "list"; } - private normalizeStoredStatuses(value: unknown): string[] { - if (!value) return []; - if (Array.isArray(value)) { - return value - .map((status) => - status === undefined || status === null - ? undefined - : String(status), - ) - .filter((status): status is string => Boolean(status)); - } - return [String(value)]; + private getStoreOptions() { + return { storeAsArray: this.shouldStoreStatusesAsArray() }; } - private writeStatusesToFrontmatter( - frontmatter: Record, - frontmatterTagName: string, - statuses: string[], - ) { - if (!statuses.length) { - if (this.shouldStoreStatusesAsArray()) { - frontmatter[frontmatterTagName] = []; - } else { - delete frontmatter[frontmatterTagName]; - } - return; - } - - if (this.shouldStoreStatusesAsArray()) { - frontmatter[frontmatterTagName] = [...statuses]; - } else { - frontmatter[frontmatterTagName] = statuses[0]; - } + private statusesAreEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + return a.every((value, index) => value === b[index]); } public populateStatuses(): void { @@ -178,34 +156,27 @@ export class NoteStatusService extends BaseNoteStatusService { return; } - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(this.file); - const frontmatter = cachedMetadata?.frontmatter; - if (!frontmatter) { - return; - } - STATUS_METADATA_KEYS.forEach((key) => { - const value = frontmatter[key]; - if (value) { - let statuses: (NoteStatusType | undefined)[] = []; - if (Array.isArray(value)) { - statuses = value.map((v) => - this.statusNameToObject(v.toString()), - ); - } else { - statuses = [this.statusNameToObject(value.toString())]; - } - - statuses = statuses.filter((s) => s !== undefined); - if ( - statuses.length && - !settingsService.settings.useMultipleStatuses - ) { - statuses = statuses.slice(0, 1); - } - this.statuses[key] = statuses as NoteStatusType[]; + const identifiers = this.statusStore.getStatuses(this.file, key); + if (!identifiers.length) { + return; } + + let statuses = identifiers + .map((identifier) => + this.statusNameToObject(identifier.toString()), + ) + .filter( + (status): status is NoteStatusType => status !== undefined, + ); + + if ( + statuses.length && + !settingsService.settings.useMultipleStatuses + ) { + statuses = statuses.slice(0, 1); + } + this.statuses[key] = statuses; }); } @@ -213,7 +184,6 @@ export class NoteStatusService extends BaseNoteStatusService { frontmatterTagName: string, status: NoteStatus, ): Promise { - let removed = false; const targetIdentifier = status.templateId ? BaseNoteStatusService.formatStatusIdentifier({ templateId: status.templateId, @@ -221,40 +191,37 @@ export class NoteStatusService extends BaseNoteStatusService { }) : status.name; - await BaseNoteStatusService.app.fileManager.processFrontMatter( + const removed = await this.statusStore.mutateStatuses( this.file, - (frontmatter) => { - const storedStatuses = this.normalizeStoredStatuses( - frontmatter?.[frontmatterTagName], - ); - if (!storedStatuses.length) return; + frontmatterTagName, + (current) => { + if (!current.length) { + return { hasChanged: false }; + } - // First try to find exact match (scoped or legacy) - let i = storedStatuses.findIndex( + let i = current.findIndex( (statusName: string) => statusName === targetIdentifier, ); - // If not found and we're looking for a scoped status, try legacy format if (i === -1 && status.templateId) { - i = storedStatuses.findIndex( + i = current.findIndex( (statusName: string) => statusName === status.name, ); } - if (i !== -1) { - storedStatuses.splice(i, 1); - this.writeStatusesToFrontmatter( - frontmatter, - frontmatterTagName, - storedStatuses, - ); - removed = true; + if (i === -1) { + return { hasChanged: false }; } + + const nextStatuses = [...current]; + nextStatuses.splice(i, 1); + return { hasChanged: true, nextStatuses }; }, + this.getStoreOptions(), ); if (removed) { - eventBus.publish("frontmatter-manually-changed", { + eventBus.publish("status-changed", { file: this.file, }); } @@ -262,20 +229,23 @@ export class NoteStatusService extends BaseNoteStatusService { } async clearStatus(frontmatterTagName: string): Promise { - await BaseNoteStatusService.app.fileManager.processFrontMatter( + const cleared = await this.statusStore.mutateStatuses( this.file, - (frontmatter) => { - if (frontmatterTagName in frontmatter) { - this.writeStatusesToFrontmatter( - frontmatter, - frontmatterTagName, - [], - ); + frontmatterTagName, + (current) => { + if (!current.length) { + return { hasChanged: false }; } + return { hasChanged: true, nextStatuses: [] }; }, + this.getStoreOptions(), ); - eventBus.publish("frontmatter-manually-changed", { file: this.file }); - return true; + if (cleared) { + eventBus.publish("status-changed", { + file: this.file, + }); + } + return cleared; } async overrideStatuses( @@ -287,29 +257,34 @@ export class NoteStatusService extends BaseNoteStatusService { ? id : BaseNoteStatusService.formatStatusIdentifier(id), ); - await BaseNoteStatusService.app.fileManager.processFrontMatter( + const statusesToStore = settingsService.settings.useMultipleStatuses + ? formattedIdentifiers + : formattedIdentifiers.slice(0, 1); + + const overridden = await this.statusStore.mutateStatuses( this.file, - (frontmatter) => { - const statusesToStore = settingsService.settings - .useMultipleStatuses - ? formattedIdentifiers - : formattedIdentifiers.slice(0, 1); - this.writeStatusesToFrontmatter( - frontmatter, - frontmatterTagName, - statusesToStore, - ); + frontmatterTagName, + (current) => { + if (this.statusesAreEqual(current, statusesToStore)) { + return { hasChanged: false }; + } + return { hasChanged: true, nextStatuses: statusesToStore }; }, + this.getStoreOptions(), ); - eventBus.publish("frontmatter-manually-changed", { file: this.file }); - return true; + + if (overridden) { + eventBus.publish("status-changed", { + file: this.file, + }); + } + return overridden; } async addStatus( frontmatterTagName: string, statusIdentifier: StatusIdentifier, ): Promise { - let added = false; const formattedIdentifier = typeof statusIdentifier === "string" ? statusIdentifier @@ -317,64 +292,51 @@ export class NoteStatusService extends BaseNoteStatusService { statusIdentifier, ); - await BaseNoteStatusService.app.fileManager.processFrontMatter( + const added = await this.statusStore.mutateStatuses( this.file, - (frontmatter) => { - const noteStatusFrontmatter = this.normalizeStoredStatuses( - frontmatter?.[frontmatterTagName], - ); + frontmatterTagName, + (current) => { if (!settingsService.settings.useMultipleStatuses) { const newValue = [formattedIdentifier]; - this.writeStatusesToFrontmatter( - frontmatter, - frontmatterTagName, - newValue, - ); - added = true; - } else { - // Check if we already have this status (exact match) - const exactMatch = noteStatusFrontmatter.findIndex( - (statusName: string) => - statusName === formattedIdentifier, - ); - - if (exactMatch === -1) { - // Check if we have a legacy version of this scoped status - let legacyIndex = -1; - if ( - typeof statusIdentifier !== "string" && - statusIdentifier.templateId - ) { - legacyIndex = noteStatusFrontmatter.findIndex( - (statusName: string) => - statusName === statusIdentifier.name, - ); - } - - if (legacyIndex !== -1) { - // Replace legacy with scoped version - noteStatusFrontmatter[legacyIndex] = - formattedIdentifier; - added = true; - } else { - // Add new status - noteStatusFrontmatter.push(formattedIdentifier); - added = true; - } - } - - if (added) { - this.writeStatusesToFrontmatter( - frontmatter, - frontmatterTagName, - noteStatusFrontmatter, - ); + if (this.statusesAreEqual(current, newValue)) { + return { hasChanged: false }; } + return { hasChanged: true, nextStatuses: newValue }; } + + const exactMatch = current.findIndex( + (statusName: string) => statusName === formattedIdentifier, + ); + + if (exactMatch !== -1) { + return { hasChanged: false }; + } + + let legacyIndex = -1; + if ( + typeof statusIdentifier !== "string" && + statusIdentifier.templateId + ) { + legacyIndex = current.findIndex( + (statusName: string) => + statusName === statusIdentifier.name, + ); + } + + const nextStatuses = [...current]; + if (legacyIndex !== -1) { + nextStatuses[legacyIndex] = formattedIdentifier; + } else { + nextStatuses.push(formattedIdentifier); + } + + return { hasChanged: true, nextStatuses }; }, + this.getStoreOptions(), ); + if (added) { - eventBus.publish("frontmatter-manually-changed", { + eventBus.publish("status-changed", { file: this.file, }); } @@ -404,23 +366,24 @@ export class MultipleNoteStatusService extends BaseNoteStatusService { const allStatuses = new Map>(); this.files.forEach((file) => { - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(file); - const frontmatter = cachedMetadata?.frontmatter; - if (!frontmatter) return; + let store: StatusStore; + try { + store = statusStoreManager.getStoreForFile(file); + } catch { + return; + } STATUS_METADATA_KEYS.forEach((key) => { - const value = frontmatter[key]; - if (value) { - if (!allStatuses.has(key)) { - allStatuses.set(key, new Set()); - } + const identifiers = store.getStatuses(file, key); + if (!identifiers.length) return; - const statusNames = Array.isArray(value) ? value : [value]; - statusNames.forEach((name) => - allStatuses.get(key)!.add(name.toString()), - ); + if (!allStatuses.has(key)) { + allStatuses.set(key, new Set()); } + + identifiers.forEach((name) => + allStatuses.get(key)!.add(name.toString()), + ); }); }); @@ -490,25 +453,20 @@ export class MultipleNoteStatusService extends BaseNoteStatusService { : status.name; return this.files.filter((file) => { - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(file); - const frontmatter = cachedMetadata?.frontmatter; - if (!frontmatter) return false; - - const value = frontmatter[frontmatterTagName]; - if (!value) return false; - - if (Array.isArray(value)) { - // Check for exact match first, then legacy format if scoped - return ( - value.includes(targetIdentifier) || - (status.templateId && value.includes(status.name)) - ); + let store: StatusStore; + try { + store = statusStoreManager.getStoreForFile(file); + } catch { + return false; } - // Check for exact match first, then legacy format if scoped - return ( - value === targetIdentifier || - (status.templateId && value === status.name) + + const identifiers = store.getStatuses(file, frontmatterTagName); + if (!identifiers.length) return false; + + return identifiers.some( + (identifier) => + identifier === targetIdentifier || + (status.templateId && identifier === status.name), ); }); } diff --git a/core/statusStoreManager.ts b/core/statusStoreManager.ts new file mode 100644 index 0000000..917b580 --- /dev/null +++ b/core/statusStoreManager.ts @@ -0,0 +1,37 @@ +import { Plugin, TFile } from "obsidian"; +import { FrontmatterStatusStore } from "./statusStores/frontmatterStatusStore"; +import { NonMarkdownStatusStore } from "./statusStores/nonMarkdownStatusStore"; +import { StatusStore } from "./statusStores/types"; + +class StatusStoreManager { + private stores: StatusStore[] = []; + + async initialize(plugin: Plugin) { + const frontmatterStore = new FrontmatterStatusStore(plugin.app); + this.stores = [frontmatterStore]; + + const nonMarkdownStore = new NonMarkdownStatusStore(plugin); + await nonMarkdownStore.initialize(); + this.registerStore(nonMarkdownStore); + } + + registerStore(store: StatusStore) { + this.stores.push(store); + } + + getStoreForFile(file: TFile): StatusStore { + const store = this.stores.find((candidate) => + candidate.canHandle(file), + ); + + if (!store) { + throw new Error( + `No status store registered for file type: ${file.extension}`, + ); + } + + return store; + } +} + +export default new StatusStoreManager(); diff --git a/core/statusStores/frontmatterStatusStore.ts b/core/statusStores/frontmatterStatusStore.ts new file mode 100644 index 0000000..308be2a --- /dev/null +++ b/core/statusStores/frontmatterStatusStore.ts @@ -0,0 +1,80 @@ +import { App, TFile } from "obsidian"; +import { StatusMutation, StatusStore } from "./types"; + +export class FrontmatterStatusStore implements StatusStore { + constructor(private readonly app: App) {} + + canHandle(file: TFile): boolean { + return file.extension === "md"; + } + + getStatuses(file: TFile, frontmatterTagName: string): string[] { + const cachedMetadata = this.app.metadataCache.getFileCache(file); + const frontmatter = cachedMetadata?.frontmatter; + if (!frontmatter) return []; + + return this.normalizeValue(frontmatter[frontmatterTagName]); + } + + async mutateStatuses( + file: TFile, + frontmatterTagName: string, + mutator: (current: string[]) => StatusMutation, + options: { storeAsArray: boolean }, + ): Promise { + let changed = false; + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + const currentStatuses = this.normalizeValue( + frontmatter?.[frontmatterTagName], + ); + const mutation = mutator([...currentStatuses]); + + if (!mutation.hasChanged) return; + + this.writeStatuses( + frontmatter, + frontmatterTagName, + mutation.nextStatuses, + options.storeAsArray, + ); + changed = true; + }); + return changed; + } + + private normalizeValue(value: unknown): string[] { + if (!value) return []; + if (Array.isArray(value)) { + return value + .map((status) => + status === undefined || status === null + ? undefined + : String(status), + ) + .filter((status): status is string => Boolean(status)); + } + return [String(value)]; + } + + private writeStatuses( + frontmatter: Record, + frontmatterTagName: string, + statuses: string[], + storeAsArray: boolean, + ) { + if (!statuses.length) { + if (storeAsArray) { + frontmatter[frontmatterTagName] = []; + } else { + delete frontmatter[frontmatterTagName]; + } + return; + } + + if (storeAsArray) { + frontmatter[frontmatterTagName] = [...statuses]; + } else { + frontmatter[frontmatterTagName] = statuses[0]; + } + } +} diff --git a/core/statusStores/nonMarkdownStatusStore.ts b/core/statusStores/nonMarkdownStatusStore.ts new file mode 100644 index 0000000..7c01531 --- /dev/null +++ b/core/statusStores/nonMarkdownStatusStore.ts @@ -0,0 +1,141 @@ +import { normalizePath, Plugin, TAbstractFile, TFile } from "obsidian"; +import eventBus from "@/core/eventBus"; +import { StatusMutation, StatusStore } from "./types"; + +type FileStatusMap = Record>; + +type PersistedData = { + version: number; + files: FileStatusMap; +}; + +export class NonMarkdownStatusStore implements StatusStore { + private data: FileStatusMap = {}; + private readonly dataPath: string; + + constructor(private readonly plugin: Plugin) { + const configDir = this.plugin.app.vault.configDir; + const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`; + this.dataPath = normalizePath( + `${pluginDir}/non-markdown-statuses.json`, + ); + } + + async initialize(): Promise { + this.data = await this.loadFromDisk(); + this.registerVaultEvents(); + } + + canHandle(file: TFile): boolean { + return file.extension !== "md"; + } + + getStatuses(file: TFile, frontmatterTagName: string): string[] { + const fileStatuses = this.data[file.path]; + if (!fileStatuses) return []; + const statuses = fileStatuses[frontmatterTagName]; + return statuses ? [...statuses] : []; + } + + async mutateStatuses( + file: TFile, + frontmatterTagName: string, + mutator: (current: string[]) => StatusMutation, + _options: { storeAsArray: boolean }, + ): Promise { + const fileStatuses = this.data[file.path] ?? {}; + const current = fileStatuses[frontmatterTagName] ?? []; + const mutation = mutator([...current]); + + if (!mutation.hasChanged) { + return false; + } + + if (mutation.nextStatuses.length) { + if (!this.data[file.path]) { + this.data[file.path] = {}; + } + this.data[file.path][frontmatterTagName] = [ + ...mutation.nextStatuses, + ]; + } else if (this.data[file.path]) { + delete this.data[file.path][frontmatterTagName]; + if (Object.keys(this.data[file.path]).length === 0) { + delete this.data[file.path]; + } + } + + await this.persistData(); + return true; + } + + private async loadFromDisk(): Promise { + try { + const raw = await this.plugin.app.vault.adapter.read(this.dataPath); + const parsed = JSON.parse(raw) as PersistedData; + if (parsed && typeof parsed === "object" && parsed.files) { + return parsed.files; + } + } catch { + // File may not exist yet or be malformed; start with empty dataset. + } + return {}; + } + + private async persistData(): Promise { + const payload: PersistedData = { + version: 1, + files: this.data, + }; + await this.ensureDirectoryExists(); + await this.plugin.app.vault.adapter.write( + this.dataPath, + JSON.stringify(payload, null, 2), + ); + } + + private async ensureDirectoryExists(): Promise { + const dir = this.dataPath.split("/").slice(0, -1).join("/"); + if (!(await this.plugin.app.vault.adapter.exists(dir))) { + await this.plugin.app.vault.adapter.mkdir(dir); + } + } + + private registerVaultEvents() { + this.plugin.registerEvent( + this.plugin.app.vault.on("rename", (file, oldPath) => { + this.handleRename(file, oldPath); + }), + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on("delete", (file) => { + this.handleDelete(file); + }), + ); + } + + private handleRename(file: TAbstractFile, oldPath: string) { + if (!(file instanceof TFile)) return; + const existing = this.data[oldPath]; + if (!existing) return; + this.data[file.path] = existing; + delete this.data[oldPath]; + this.persistData() + .then(() => { + eventBus.publish("status-changed", { file }); + }) + .catch(console.error); + } + + private handleDelete(file: TAbstractFile) { + if (!(file instanceof TFile)) return; + if (!this.data[file.path]) return; + delete this.data[file.path]; + this.persistData() + .then(() => { + eventBus.publish("status-changed", { file }); + }) + .catch(console.error); + } +} diff --git a/core/statusStores/types.ts b/core/statusStores/types.ts new file mode 100644 index 0000000..f6a2191 --- /dev/null +++ b/core/statusStores/types.ts @@ -0,0 +1,18 @@ +import { TFile } from "obsidian"; + +export type StatusMutation = + | { hasChanged: false } + | { hasChanged: true; nextStatuses: string[] }; + +export type StatusMutator = (current: string[]) => StatusMutation; + +export interface StatusStore { + canHandle(file: TFile): boolean; + getStatuses(file: TFile, frontmatterTagName: string): string[]; + mutateStatuses( + file: TFile, + frontmatterTagName: string, + mutator: StatusMutator, + options: { storeAsArray: boolean }, + ): Promise; +} diff --git a/integrations/context-menu/contextMenuIntegration.tsx b/integrations/context-menu/contextMenuIntegration.tsx index 5edc931..fb90129 100644 --- a/integrations/context-menu/contextMenuIntegration.tsx +++ b/integrations/context-menu/contextMenuIntegration.tsx @@ -49,13 +49,14 @@ export class ContextMenuIntegration { const tFiles = files.filter( (f): f is TFile => f instanceof TFile, ); - if (tFiles.length) { - this.openMultipleFilesStatusesModal(tFiles); - } else { + if (!tFiles.length) { new Notice( - "The selected files are not valid to add status, just .md files can have status in this plugin version", + "Select at least one file to change its status.", ); + return; } + + this.openMultipleFilesStatusesModal(tFiles); }); }); }, @@ -85,17 +86,17 @@ export class ContextMenuIntegration { item.setTitle("Change note status") .setIcon("rotate-ccw") // Lucide icon .onClick(async () => { - if (view.file) { - if (view.file instanceof TFile) { - this.openSingleFileStatusesModal( - view.file, - ); - } else { - new Notice( - "The selected file is not valid to add status, just .md files can have status in this plugin version", - ); - } + if ( + !view.file || + !(view.file instanceof TFile) + ) { + new Notice( + "The selected item is not a valid file.", + ); + return; } + + this.openSingleFileStatusesModal(view.file); }); }); }, @@ -154,25 +155,22 @@ export class ContextMenuIntegration { folder: TFolder, includeSubfolders: boolean, ): void { - const markdownFiles = this.getMarkdownFilesFromFolder( - folder, - includeSubfolders, - ); + const files = this.getFilesFromFolder(folder, includeSubfolders); - if (!markdownFiles.length) { - new Notice("This folder does not contain any Markdown notes."); + if (!files.length) { + new Notice("This folder does not contain any files."); return; } const warningThreshold = 50; - if (markdownFiles.length >= warningThreshold) { + if (files.length >= warningThreshold) { new Notice( - `This folder contains ${markdownFiles.length} notes. Applying status changes may take a while.`, + `This folder contains ${files.length} files. Applying status changes may take a while.`, 8000, ); } - const multiStatusService = new MultipleNoteStatusService(markdownFiles); + const multiStatusService = new MultipleNoteStatusService(files); multiStatusService.populateStatuses(); eventBus.publish("triggered-open-modal", { @@ -180,7 +178,7 @@ export class ContextMenuIntegration { }); } - private getMarkdownFilesFromFolder( + private getFilesFromFolder( folder: TFolder, includeSubfolders: boolean, ): TFile[] { @@ -193,9 +191,7 @@ export class ContextMenuIntegration { current.children.forEach((child) => { if (child instanceof TFile) { - if (child.extension === "md") { - files.push(child); - } + files.push(child); } else if (includeSubfolders && child instanceof TFolder) { queue.push(child); } diff --git a/integrations/file-explorer/file-explorer-integration.tsx b/integrations/file-explorer/file-explorer-integration.tsx index 35e3469..5a9b40b 100644 --- a/integrations/file-explorer/file-explorer-integration.tsx +++ b/integrations/file-explorer/file-explorer-integration.tsx @@ -33,7 +33,7 @@ export class FileExplorerIntegration implements IElementProcessor { this.setupWorkspaceIntegration(); eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", ({ file }) => { const element = this.findFileElement(file.path); if (!element) return; @@ -74,18 +74,13 @@ export class FileExplorerIntegration implements IElementProcessor { private getFileNoteStatusService( dataPath: string, ): NoteStatusService | null { - if (!dataPath.endsWith(".md")) { + const abstractFile = + this.plugin.app.vault.getAbstractFileByPath(dataPath); + if (!(abstractFile instanceof TFile)) { return null; } - const file = this.plugin.app.vault.getAbstractFileByPath( - dataPath, - ) as TFile; - if (!file) { - return null; - } - - const noteStatusService = new NoteStatusService(file); + const noteStatusService = new NoteStatusService(abstractFile); noteStatusService.populateStatuses(); return noteStatusService; } @@ -187,12 +182,9 @@ export class FileExplorerIntegration implements IElementProcessor { */ destroy(): void { this.observerService.cleanup(); + eventBus.unsubscribe("status-changed", this.EVENT_SUBSCRIPTION_ID); eventBus.unsubscribe( - "frontmatter-manually-changed", - this.EVENT_SUBSCRIPTION_ID, - ); - eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "fileExplorerIntegrationSubscription2", ); } diff --git a/integrations/modals/statusModalIntegration.tsx b/integrations/modals/statusModalIntegration.tsx index 19b9c2a..725d818 100644 --- a/integrations/modals/statusModalIntegration.tsx +++ b/integrations/modals/statusModalIntegration.tsx @@ -35,7 +35,7 @@ export class StatusModalIntegration extends Modal { ); eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", () => { // TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations // FIXME: Line 27 @@ -141,7 +141,7 @@ export class StatusModalIntegration extends Modal { StatusModalIntegration.instance = null; eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "statusModalIntegrationSubscription1", ); } diff --git a/integrations/status-bar/status-bar.tsx b/integrations/status-bar/status-bar.tsx index 34c105e..ad98055 100644 --- a/integrations/status-bar/status-bar.tsx +++ b/integrations/status-bar/status-bar.tsx @@ -1,6 +1,6 @@ import { createRoot, Root } from "react-dom/client"; import eventBus from "core/eventBus"; -import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian"; +import { Plugin } from "obsidian"; import settingsService from "@/core/settingsService"; import { StatusBar } from "@/components/StatusBar/StatusBar"; import { NoteStatusService } from "@/core/noteStatusService"; @@ -11,7 +11,6 @@ export class StatusBarIntegration { private plugin: Plugin; private statusBarContainer: HTMLElement | null = null; private noteStatusService: NoteStatusService | null = null; - private currentLeaf: WorkspaceLeaf | null = null; constructor(plugin: Plugin) { if (StatusBarIntegration.instance) { @@ -24,11 +23,8 @@ export class StatusBarIntegration { async integrate() { eventBus.subscribe( "active-file-change", - ({ leaf }) => { - if (this.isValidMarkdownLeaf(leaf)) { - this.currentLeaf = leaf; - this.handleActiveFileChange().catch(console.error); - } + () => { + this.handleActiveFileChange(); }, "statusBarIntegrationSubscription1", ); @@ -75,7 +71,7 @@ export class StatusBarIntegration { ); eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", () => { this.handleActiveFileChange().catch(console.error); }, @@ -83,27 +79,17 @@ export class StatusBarIntegration { ); } - private async handleActiveFileChange() { - this.extractStatusesFromLeaf(this.currentLeaf); - this.render(); - } - - private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) { - if (!this.isValidMarkdownLeaf(leaf)) { - return {}; + private async handleActiveFileChange(): Promise { + const file = this.plugin.app.workspace.getActiveFile(); + if (!file) { + this.noteStatusService = null; + this.render(); + return; } - const markdownView = leaf!.view as MarkdownView; - if (!markdownView.file) { - return {}; - } - - this.noteStatusService = new NoteStatusService(markdownView.file); + this.noteStatusService = new NoteStatusService(file); this.noteStatusService.populateStatuses(); - } - - private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean { - return leaf !== null && leaf.view.getViewType() === "markdown"; + this.render(); } private openStatusModal() { @@ -181,12 +167,11 @@ export class StatusBarIntegration { "statusBarIntegrationSubscription2", ); eventBus.unsubscribe( - "plugin-settings-changed", + "status-changed", "statusBarIntegrationSubscription3", ); this.destroyStatusBarItem(); - this.currentLeaf = null; this.noteStatusService = null; StatusBarIntegration.instance = null; } diff --git a/integrations/toolbar/editorToolbarIntegration.tsx b/integrations/toolbar/editorToolbarIntegration.tsx index ddb00b0..dc9e3ba 100644 --- a/integrations/toolbar/editorToolbarIntegration.tsx +++ b/integrations/toolbar/editorToolbarIntegration.tsx @@ -98,7 +98,7 @@ export class EditorToolbarIntegration { ); eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", ({ file }) => { // Find the button for the specific file and refresh only that one for (const [leaf, leafButton] of this.leafButtons.entries()) { @@ -394,7 +394,7 @@ export class EditorToolbarIntegration { "editorToolbarIntegrationSubscription2", ); eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "editorToolbarIntegrationSubscription3", ); diff --git a/integrations/views/grouped-dashboard-view.tsx b/integrations/views/grouped-dashboard-view.tsx index db0df26..877ef92 100644 --- a/integrations/views/grouped-dashboard-view.tsx +++ b/integrations/views/grouped-dashboard-view.tsx @@ -1,7 +1,10 @@ import { ItemView, WorkspaceLeaf, TFile } from "obsidian"; import { Root, createRoot } from "react-dom/client"; import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView"; -import { BaseNoteStatusService } from "@/core/noteStatusService"; +import { + BaseNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; import eventBus from "@/core/eventBus"; import settingsService from "@/core/settingsService"; import { NoteStatus } from "@/types/noteStatus"; @@ -62,7 +65,7 @@ export class GroupedDashboardView extends ItemView { }); private getAllFiles = (): FileItem[] => { - const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); + const tFiles = BaseNoteStatusService.app.vault.getFiles(); return tFiles.map(this.convertTFileToFileItem); }; @@ -71,16 +74,6 @@ export class GroupedDashboardView extends ItemView { const statusMetadataKeys = [settingsService.settings.tagPrefix]; const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses(); - // Create maps for both scoped and legacy status lookup - const statusMap = new Map( - availableStatuses.map((s) => { - const key = s.templateId ? `${s.templateId}:${s.name}` : s.name; - return [key, s]; - }), - ); - const legacyStatusMap = new Map( - availableStatuses.map((s) => [s.name, s]), - ); statusMetadataKeys.forEach((key) => { result[key] = {}; @@ -93,41 +86,27 @@ export class GroupedDashboardView extends ItemView { }); files.forEach((file) => { - // Find the TFile to get metadata const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( file.path, ) as TFile; if (!tFile) return; - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(tFile); - const frontmatter = cachedMetadata?.frontmatter; - - if (!frontmatter) return; + const noteStatusService = new NoteStatusService(tFile); + noteStatusService.populateStatuses(); statusMetadataKeys.forEach((key) => { - const value = frontmatter[key]; - if (value) { - const statusNames = Array.isArray(value) ? value : [value]; - statusNames.forEach((statusName) => { - const statusStr = statusName.toString(); - // Try to find status by exact match first, then by legacy name - let resolvedStatus = statusMap.get(statusStr); - if (!resolvedStatus) { - resolvedStatus = legacyStatusMap.get(statusStr); - } + const statusesForKey = noteStatusService.statuses[key] || []; + if (!statusesForKey.length) return; - if (resolvedStatus) { - const statusKey = resolvedStatus.templateId - ? `${resolvedStatus.templateId}:${resolvedStatus.name}` - : resolvedStatus.name; - if (!result[key][statusKey]) { - result[key][statusKey] = []; - } - result[key][statusKey].push(file); - } - }); - } + statusesForKey.forEach((status) => { + const statusKey = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + if (!result[key][statusKey]) { + result[key][statusKey] = []; + } + result[key][statusKey].push(file); + }); }); }); @@ -155,7 +134,7 @@ export class GroupedDashboardView extends ItemView { private subscribeToEvents = (onDataChange: () => void) => { eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", onDataChange, "grouped-dashboard-view-subscription", ); @@ -182,7 +161,7 @@ export class GroupedDashboardView extends ItemView { return () => { eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "grouped-dashboard-view-subscription", ); eventBus.unsubscribe( diff --git a/integrations/views/grouped-status-view.tsx b/integrations/views/grouped-status-view.tsx index 768d5bd..5e94a54 100644 --- a/integrations/views/grouped-status-view.tsx +++ b/integrations/views/grouped-status-view.tsx @@ -6,7 +6,10 @@ import { GroupedStatusView as GroupedStatusViewComponent, StatusItem, } from "@/components/GroupedStatusView/GroupedStatusView"; -import { BaseNoteStatusService } from "@/core/noteStatusService"; +import { + BaseNoteStatusService, + NoteStatusService, +} from "@/core/noteStatusService"; import eventBus from "@/core/eventBus"; import settingsService from "@/core/settingsService"; import { NoteStatus } from "@/types/noteStatus"; @@ -46,7 +49,7 @@ export class GroupedStatusView extends ItemView { }); private getAllFiles = (): FileItem[] => { - const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); + const tFiles = BaseNoteStatusService.app.vault.getFiles(); return tFiles.map(this.convertTFileToFileItem); }; @@ -55,16 +58,6 @@ export class GroupedStatusView extends ItemView { const statusMetadataKeys = [settingsService.settings.tagPrefix]; const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses(); - // Create maps for both scoped and legacy status lookup - const statusMap = new Map( - availableStatuses.map((s) => { - const key = s.templateId ? `${s.templateId}:${s.name}` : s.name; - return [key, s]; - }), - ); - const legacyStatusMap = new Map( - availableStatuses.map((s) => [s.name, s]), - ); statusMetadataKeys.forEach((key) => { result[key] = {}; @@ -77,41 +70,27 @@ export class GroupedStatusView extends ItemView { }); files.forEach((file) => { - // Find the TFile to get metadata const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( file.path, ) as TFile; if (!tFile) return; - const cachedMetadata = - BaseNoteStatusService.app.metadataCache.getFileCache(tFile); - const frontmatter = cachedMetadata?.frontmatter; - - if (!frontmatter) return; + const noteStatusService = new NoteStatusService(tFile); + noteStatusService.populateStatuses(); statusMetadataKeys.forEach((key) => { - const value = frontmatter[key]; - if (value) { - const statusNames = Array.isArray(value) ? value : [value]; - statusNames.forEach((statusName) => { - const statusStr = statusName.toString(); - // Try to find status by exact match first, then by legacy name - let resolvedStatus = statusMap.get(statusStr); - if (!resolvedStatus) { - resolvedStatus = legacyStatusMap.get(statusStr); - } + const statusesForKey = noteStatusService.statuses[key] || []; + if (!statusesForKey.length) return; - if (resolvedStatus) { - const statusKey = resolvedStatus.templateId - ? `${resolvedStatus.templateId}:${resolvedStatus.name}` - : resolvedStatus.name; - if (!result[key][statusKey]) { - result[key][statusKey] = []; - } - result[key][statusKey].push(file); - } - }); - } + statusesForKey.forEach((status) => { + const statusKey = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + if (!result[key][statusKey]) { + result[key][statusKey] = []; + } + result[key][statusKey].push(file); + }); }); }); @@ -139,7 +118,7 @@ export class GroupedStatusView extends ItemView { private subscribeToEvents = (onDataChange: () => void) => { eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", onDataChange, "grouped-status-view-subscription", ); @@ -167,7 +146,7 @@ export class GroupedStatusView extends ItemView { return () => { eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "grouped-status-view-subscription", ); eventBus.unsubscribe( @@ -184,14 +163,14 @@ export class GroupedStatusView extends ItemView { container.addClass("grouped-status-view-container"); // Check if vault exceeds the size limit - const files = BaseNoteStatusService.app.vault.getMarkdownFiles(); + const files = BaseNoteStatusService.app.vault.getFiles(); const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000; if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) { // Show disabled message container.createEl("div", { cls: "grouped-status-view-disabled", - text: `Grouped Status View disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, + text: `Grouped Status View disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, }); return; } diff --git a/integrations/views/status-dashboard-view.tsx b/integrations/views/status-dashboard-view.tsx index c971f78..d503a43 100644 --- a/integrations/views/status-dashboard-view.tsx +++ b/integrations/views/status-dashboard-view.tsx @@ -58,7 +58,7 @@ export class StatusDashboardView extends ItemView { } private calculateVaultStats = (): VaultStats => { - const files = this.app.vault.getMarkdownFiles(); + const files = this.app.vault.getFiles(); const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses(); const statusMetadataKeys = [settingsService.settings.tagPrefix]; @@ -80,49 +80,26 @@ export class StatusDashboardView extends ItemView { }); files.forEach((file) => { - const cachedMetadata = this.app.metadataCache.getFileCache(file); - const frontmatter = cachedMetadata?.frontmatter; - - if (!frontmatter) return; - + const noteStatusService = new NoteStatusService(file); + noteStatusService.populateStatuses(); let hasAnyStatus = false; statusMetadataKeys.forEach((key) => { - const value = frontmatter[key]; - if (value) { - hasAnyStatus = true; - tagDistribution[key]++; + const statuses = noteStatusService.statuses[key] || []; + if (!statuses.length) return; - const statusNames = Array.isArray(value) ? value : [value]; - statusNames.forEach((statusName) => { - const statusStr = statusName.toString(); + hasAnyStatus = true; + tagDistribution[key]++; - // Determine the scoped identifier to use - let scopedIdentifier: string; - - if (statusStr.includes(":")) { - // Already scoped - scopedIdentifier = statusStr; - } else { - // Legacy status - find first template that has this status - const firstTemplateWithStatus = - availableStatuses.find( - (s) => s.name === statusStr && s.templateId, - ); - scopedIdentifier = firstTemplateWithStatus - ? `${firstTemplateWithStatus.templateId}:${statusStr}` - : statusStr; // Fallback to unscoped if no template found - } - - // Initialize status if not already present - if ( - !statusDistribution.hasOwnProperty(scopedIdentifier) - ) { - statusDistribution[scopedIdentifier] = 0; - } - statusDistribution[scopedIdentifier]++; - }); - } + statuses.forEach((status) => { + const scopedIdentifier = status.templateId + ? `${status.templateId}:${status.name}` + : status.name; + if (!statusDistribution.hasOwnProperty(scopedIdentifier)) { + statusDistribution[scopedIdentifier] = 0; + } + statusDistribution[scopedIdentifier]++; + }); }); if (hasAnyStatus) { @@ -305,14 +282,14 @@ export class StatusDashboardView extends ItemView { container.addClass("status-dashboard-view-container"); // Check if vault exceeds the size limit - const files = this.app.vault.getMarkdownFiles(); + const files = this.app.vault.getFiles(); const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000; if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) { // Show disabled message container.createEl("div", { cls: "status-dashboard-disabled", - text: `Dashboard disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, + text: `Dashboard disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, }); return; } @@ -329,7 +306,7 @@ export class StatusDashboardView extends ItemView { }; eventBus.subscribe( - "frontmatter-manually-changed", + "status-changed", handleVaultChange, "status-dashboard-vault-subscription", ); @@ -366,7 +343,7 @@ export class StatusDashboardView extends ItemView { async onClose() { // Clean up event listeners eventBus.unsubscribe( - "frontmatter-manually-changed", + "status-changed", "status-dashboard-vault-subscription", ); eventBus.unsubscribe( diff --git a/main.tsx b/main.tsx index 8655117..81aa831 100644 --- a/main.tsx +++ b/main.tsx @@ -1,4 +1,4 @@ -import { Plugin, WorkspaceLeaf } from "obsidian"; +import { Plugin, WorkspaceLeaf, TFile } from "obsidian"; import StatusBarIntegration from "integrations/status-bar/status-bar"; import eventBus from "core/eventBus"; import { PluginSettingIntegration } from "./integrations/settings/pluginSettings"; @@ -18,6 +18,7 @@ import { VIEW_TYPE_STATUS_DASHBOARD, } from "./integrations/views/status-dashboard-view"; import { StatusesInfoPopup } from "./integrations/popups/statusesInfoPopupIntegration"; +import statusStoreManager from "./core/statusStoreManager"; export default class NoteStatusPlugin extends Plugin { private statusBarIntegration: StatusBarIntegration; @@ -29,6 +30,7 @@ export default class NoteStatusPlugin extends Plugin { async onload() { BaseNoteStatusService.initialize(this.app); + await statusStoreManager.initialize(this); await this.loadPluginSettings(); // INFO: initialize all integrations @@ -158,7 +160,7 @@ export default class NoteStatusPlugin extends Plugin { // Propagate to custom event bus the manually frontmatter data this.registerEvent( this.app.metadataCache.on("changed", (file) => { - eventBus.publish("frontmatter-manually-changed", { file }); + eventBus.publish("status-changed", { file }); }), ); @@ -180,6 +182,22 @@ export default class NoteStatusPlugin extends Plugin { }, "main-status-popup-setting-subscriptor", ); + + this.registerEvent( + this.app.vault.on("rename", (file) => { + if (file instanceof TFile) { + eventBus.publish("status-changed", { file }); + } + }), + ); + + this.registerEvent( + this.app.vault.on("delete", (file) => { + if (file instanceof TFile) { + eventBus.publish("status-changed", { file }); + } + }), + ); } async loadPluginSettings() { diff --git a/types/eventBus.ts b/types/eventBus.ts index 7eb65a4..cc6aee4 100644 --- a/types/eventBus.ts +++ b/types/eventBus.ts @@ -16,7 +16,7 @@ export type EventBusEvents = { value: unknown; currentSettings: PluginSettings; }) => void; - "frontmatter-manually-changed": ({ file }: { file: TFile }) => void; + "status-changed": ({ file }: { file: TFile }) => void; "triggered-open-modal": ({ statusService, }: {