import { App, TFile } from "obsidian"; import { GroupedStatuses, NoteStatus, NoteStatus as NoteStatusType, StatusIdentifier, ScopedStatusName, } from "@/types/noteStatus"; import settingsService from "@/core/settingsService"; import eventBus from "./eventBus"; import statusStoreManager from "./statusStoreManager"; import type { StatusStore } from "./statusStores/types"; import { resolveFrontmatterKeysForStatus } from "@/utils/frontmatterMappings"; import { getFrontmatterKeysForFile } from "./statusKeyHelpers"; export abstract class BaseNoteStatusService { static app: App; statuses: GroupedStatuses; constructor() { this.statuses = {}; } static initialize(app: App) { BaseNoteStatusService.app = app; } static parseStatusIdentifier( identifier: StatusIdentifier, ): ScopedStatusName { if (typeof identifier === "string") { if (identifier.includes(":")) { const [templateId, name] = identifier.split(":", 2); return { templateId, name }; } return { name: identifier }; } return identifier; } static formatStatusIdentifier(scopedName: ScopedStatusName): string { if (scopedName.templateId) { return `${scopedName.templateId}:${scopedName.name}`; } return scopedName.name; } static resolveStatusFromIdentifier( identifier: StatusIdentifier, ): NoteStatus | undefined { const parsed = BaseNoteStatusService.parseStatusIdentifier(identifier); const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses(); if (parsed.templateId) { return availableStatuses.find( (s) => s.name === parsed.name && s.templateId === parsed.templateId, ); } return availableStatuses.find((s) => s.name === parsed.name); } private static allEnabledTemplatesStatuses(): NoteStatusType[] { if (settingsService.settings.useCustomStatusesOnly) { return []; } const enabledTemplates = settingsService.settings.templates.filter( (template) => settingsService.settings.enabledTemplates.includes(template.id), ); return enabledTemplates.flatMap((t) => t.statuses); } static getAllAvailableStatuses(): NoteStatus[] { const availableStatuses = [ ...settingsService.settings.customStatuses, ...BaseNoteStatusService.allEnabledTemplatesStatuses(), ]; return availableStatuses; } // static async insertStatusMetadataInEditor(file: TFile): Promise { // const tagPrefix = settingsService.settings.tagPrefix; // // await BaseNoteStatusService.app.fileManager.processFrontMatter( // file, // (frontmatter) => { // const noteStatusFrontmatter = // (frontmatter?.[tagPrefix] as string[]) || []; // if (!noteStatusFrontmatter.length) { // frontmatter[tagPrefix] = []; // } // }, // ); // } protected statusNameToObject(statusName: string | StatusIdentifier) { if (typeof statusName === "string") { return BaseNoteStatusService.resolveStatusFromIdentifier( statusName, ); } return BaseNoteStatusService.resolveStatusFromIdentifier(statusName); } protected toScopedStatusName( status: StatusIdentifier | NoteStatus, ): ScopedStatusName { if (typeof status === "string") { return BaseNoteStatusService.parseStatusIdentifier(status); } return { name: status.name, templateId: status.templateId, }; } protected hasExplicitMappingForStatus( status: StatusIdentifier | NoteStatus, ): boolean { const mappings = settingsService.settings.statusFrontmatterMappings ?? []; if (!mappings.length) { return false; } const scoped = this.toScopedStatusName(status); return mappings.some((mapping) => { if (mapping.scope === "status") { return ( mapping.statusName === scoped.name && (mapping.templateId || null) === (scoped.templateId || null) ); } if (mapping.scope === "template") { return ( Boolean(scoped.templateId) && mapping.templateId === scoped.templateId ); } return false; }); } protected getStatusMetadataKeys(): string[] { return [settingsService.settings.tagPrefix]; } abstract populateStatuses(): void; abstract removeStatus( frontmatterTagName: string, status: NoteStatus, ): Promise; abstract addStatus( frontmatterTagName: string, statusIdentifier: StatusIdentifier, ): Promise; } export class NoteStatusService extends BaseNoteStatusService { private file: TFile; private statusStore: StatusStore; constructor(file: TFile) { super(); this.file = file; this.statusStore = statusStoreManager.getStoreForFile(file); } private isMarkdownFile(): boolean { return this.file.extension === "md"; } private collectIdentifiersForFile(): string[] { const keysToRead = getFrontmatterKeysForFile(this.file); const seen = new Set(); const identifiers: string[] = []; keysToRead.forEach((key) => { const values = this.statusStore.getStatuses(this.file, key); values.forEach((value) => { const normalized = value?.toString(); if (!normalized || seen.has(normalized)) { return; } seen.add(normalized); identifiers.push(normalized); }); }); return identifiers; } private buildStatusesArray(identifiers: string[]): NoteStatusType[] { if (!identifiers.length) { return []; } let statuses = identifiers .map((identifier) => this.statusNameToObject(identifier)) .filter((status): status is NoteStatusType => status !== undefined); if (statuses.length && !settingsService.settings.useMultipleStatuses) { statuses = statuses.slice(0, 1); } return statuses; } private shouldStoreStatusesAsArray(): boolean { if (settingsService.settings.useMultipleStatuses) { return true; } const mode = settingsService.settings.singleStatusStorageMode || "list"; return mode === "list"; } private getStoreOptions() { return { storeAsArray: this.shouldStoreStatusesAsArray() }; } private resolveTargetKeys( frontmatterTagName: string, status: StatusIdentifier | NoteStatus, options?: { includeSourceKey?: boolean }, ): string[] { if (!this.isMarkdownFile()) { return [frontmatterTagName]; } let resolved = resolveFrontmatterKeysForStatus( status, settingsService.settings, { isMarkdownFile: true }, ); const defaultKey = settingsService.settings.tagPrefix; const mappingMatched = this.hasExplicitMappingForStatus(status); if ( resolved.length === 1 && resolved[0] === defaultKey && frontmatterTagName !== defaultKey ) { resolved = [frontmatterTagName]; } if ( mappingMatched && settingsService.settings.writeMappedTagsToDefault && defaultKey && this.isMarkdownFile() ) { resolved = [...resolved, defaultKey]; } if (options?.includeSourceKey && frontmatterTagName) { resolved = [...resolved, frontmatterTagName]; } const uniqueKeys = Array.from( new Set(resolved.filter((key) => key && key.trim().length)), ); return uniqueKeys.length ? uniqueKeys : [frontmatterTagName]; } private async runAcrossKeys( keys: string[], action: (key: string) => Promise, ): Promise { let hasChanged = false; for (const key of keys) { const changed = await action(key); if (changed) { hasChanged = true; } } return hasChanged; } 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 { const defaultKey = settingsService.settings.tagPrefix; this.statuses = { [defaultKey]: [] }; if (!this.file) { return; } const identifiers = this.collectIdentifiersForFile(); if (!identifiers.length) { return; } this.statuses[defaultKey] = this.buildStatusesArray(identifiers); } public getStatusesForKey(frontmatterTagName: string): NoteStatusType[] { const isValidKey = !this.isMarkdownFile() && frontmatterTagName !== settingsService.settings.tagPrefix ? false : true; if (!isValidKey) { return []; } const identifiers = this.statusStore .getStatuses(this.file, frontmatterTagName) .map((identifier) => identifier?.toString()) .filter((identifier): identifier is string => Boolean(identifier)); return this.buildStatusesArray(identifiers); } public getStatusesByAllKeys(): GroupedStatuses { const keys = getFrontmatterKeysForFile(this.file); const grouped: GroupedStatuses = {}; let hasStatuses = false; keys.forEach((key) => { const statuses = this.getStatusesForKey(key); if (!statuses.length) { return; } grouped[key] = statuses; hasStatuses = true; }); if (!hasStatuses) { grouped[settingsService.settings.tagPrefix] = this.statuses[settingsService.settings.tagPrefix] ?? []; } return grouped; } async removeStatus( frontmatterTagName: string, status: NoteStatus, ): Promise { const targetKeys = this.resolveTargetKeys(frontmatterTagName, status, { includeSourceKey: true, }); const removed = await this.runAcrossKeys(targetKeys, (key) => this.removeStatusFromKey(key, status), ); if (removed) { eventBus.publish("status-changed", { file: this.file, }); } return removed; } private async removeStatusFromKey( frontmatterTagName: string, status: NoteStatus, ): Promise { const targetIdentifier = status.templateId ? BaseNoteStatusService.formatStatusIdentifier({ templateId: status.templateId, name: status.name, }) : status.name; return this.statusStore.mutateStatuses( this.file, frontmatterTagName, (current) => { if (!current.length) { return { hasChanged: false }; } let i = current.findIndex( (statusName: string) => statusName === targetIdentifier, ); if (i === -1 && status.templateId) { i = current.findIndex( (statusName: string) => statusName === status.name, ); } if (i === -1) { return { hasChanged: false }; } const nextStatuses = [...current]; nextStatuses.splice(i, 1); return { hasChanged: true, nextStatuses }; }, this.getStoreOptions(), ); } async clearStatus(frontmatterTagName: string): Promise { const keys = new Set([frontmatterTagName]); if (this.isMarkdownFile()) { getFrontmatterKeysForFile(this.file).forEach((key) => keys.add(key), ); } const cleared = await this.runAcrossKeys([...keys], (key) => this.clearStatusForKey(key), ); if (cleared) { eventBus.publish("status-changed", { file: this.file, }); } return cleared; } private async clearStatusForKey( frontmatterTagName: string, ): Promise { return this.statusStore.mutateStatuses( this.file, frontmatterTagName, (current) => { if (!current.length) { return { hasChanged: false }; } return { hasChanged: true, nextStatuses: [] }; }, this.getStoreOptions(), ); } async overrideStatuses( frontmatterTagName: string, statusIdentifiers: StatusIdentifier[], ): Promise { const limitedIdentifiers = settingsService.settings.useMultipleStatuses ? statusIdentifiers : statusIdentifiers.slice(0, 1); if (!limitedIdentifiers.length) { return this.clearStatus(frontmatterTagName); } const keyMap = new Map(); if (!this.isMarkdownFile()) { keyMap.set(frontmatterTagName, limitedIdentifiers); } else { limitedIdentifiers.forEach((identifier) => { const keys = this.resolveTargetKeys( frontmatterTagName, identifier, ); keys.forEach((key) => { const currentList = keyMap.get(key) ?? []; currentList.push(identifier); keyMap.set(key, currentList); }); }); const knownKeys = new Set([ frontmatterTagName, ...getFrontmatterKeysForFile(this.file), ]); knownKeys.forEach((key) => { if (!keyMap.has(key)) { keyMap.set(key, []); } }); } const overridden = await this.runAcrossKeys( Array.from(keyMap.keys()), (key) => this.overrideStatusesForKey(key, keyMap.get(key) ?? []), ); if (overridden) { eventBus.publish("status-changed", { file: this.file, }); } return overridden; } private async overrideStatusesForKey( frontmatterTagName: string, statusIdentifiers: StatusIdentifier[], ): Promise { const formattedIdentifiers = statusIdentifiers.map((id) => typeof id === "string" ? id : BaseNoteStatusService.formatStatusIdentifier(id), ); return this.statusStore.mutateStatuses( this.file, frontmatterTagName, (current) => { if (this.statusesAreEqual(current, formattedIdentifiers)) { return { hasChanged: false }; } return { hasChanged: true, nextStatuses: formattedIdentifiers, }; }, this.getStoreOptions(), ); } async addStatus( frontmatterTagName: string, statusIdentifier: StatusIdentifier, ): Promise { const targetKeys = this.resolveTargetKeys( frontmatterTagName, statusIdentifier, ); const added = await this.runAcrossKeys(targetKeys, (key) => this.addStatusToKey(key, statusIdentifier), ); if (added) { eventBus.publish("status-changed", { file: this.file, }); } return added; } private async addStatusToKey( frontmatterTagName: string, statusIdentifier: StatusIdentifier, ): Promise { const formattedIdentifier = typeof statusIdentifier === "string" ? statusIdentifier : BaseNoteStatusService.formatStatusIdentifier( statusIdentifier, ); return this.statusStore.mutateStatuses( this.file, frontmatterTagName, (current) => { if (!settingsService.settings.useMultipleStatuses) { const newValue = [formattedIdentifier]; 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(), ); } } export class MultipleNoteStatusService extends BaseNoteStatusService { private files: TFile[]; constructor(files: TFile[]) { super(); this.files = files; } selectedFilesQTY() { return this.files.length; } public populateStatuses(): void { const defaultKey = settingsService.settings.tagPrefix; this.statuses = { [defaultKey]: [] }; const aggregated = new Set(); this.files.forEach((file) => { let store: StatusStore; try { store = statusStoreManager.getStoreForFile(file); } catch { return; } const keys = getFrontmatterKeysForFile(file); keys.forEach((key) => { const identifiers = store.getStatuses(file, key); identifiers.forEach((identifier) => { const normalized = identifier?.toString(); if (normalized && !aggregated.has(normalized)) { aggregated.add(normalized); } }); }); }); if (!aggregated.size) { return; } const statuses = Array.from(aggregated) .map((identifier) => this.statusNameToObject(identifier)) .filter((status): status is NoteStatusType => status !== undefined); this.statuses[defaultKey] = statuses; } async removeStatus( frontmatterTagName: string, status: NoteStatus, ): Promise { const removalResults = await Promise.all( this.files.map(async (file) => { const noteStatusService = new NoteStatusService(file); return noteStatusService.removeStatus( frontmatterTagName, status, ); }), ); const removedFromAny = removalResults.some((result) => result); if (removedFromAny) { this.populateStatuses(); } return removedFromAny; } async addStatus( frontmatterTagName: string, statusIdentifier: StatusIdentifier, ): Promise { const results = await Promise.all( this.files.map(async (file) => { const noteStatusService = new NoteStatusService(file); return noteStatusService.addStatus( frontmatterTagName, statusIdentifier, ); }), ); const addedToAny = results.some((result) => result); if (addedToAny) { this.populateStatuses(); } return addedToAny; } getFilesWithStatus( frontmatterTagName: string, status: NoteStatus, ): TFile[] { const targetIdentifier = status.templateId ? BaseNoteStatusService.formatStatusIdentifier({ templateId: status.templateId, name: status.name, }) : status.name; return this.files.filter((file) => { let store: StatusStore; try { store = statusStoreManager.getStoreForFile(file); } catch { return false; } const identifiers = store.getStatuses(file, frontmatterTagName); if (!identifiers.length) return false; return identifiers.some( (identifier) => identifier === targetIdentifier || (status.templateId && identifier === status.name), ); }); } }