feat: extension to non-markdown files

This commit is contained in:
Aleix Soler 2025-11-21 12:51:08 +01:00
parent 6ea99c29fa
commit ee94f6b13c
18 changed files with 571 additions and 430 deletions

View file

@ -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:

View file

@ -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) {

View file

@ -4,7 +4,7 @@ class EventBus {
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
"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<EventBusEvents["plugin-settings-changed"]>
): void;
publish(
event: "frontmatter-manually-changed",
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
event: "status-changed",
...args: Parameters<EventBusEvents["status-changed"]>
): void;
publish(
event: "triggered-open-modal",

View file

@ -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<string, unknown>,
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<boolean> {
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<boolean> {
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<boolean> {
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<string, Set<string>>();
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),
);
});
}

View file

@ -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();

View file

@ -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<boolean> {
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<string, unknown>,
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];
}
}
}

View file

@ -0,0 +1,141 @@
import { normalizePath, Plugin, TAbstractFile, TFile } from "obsidian";
import eventBus from "@/core/eventBus";
import { StatusMutation, StatusStore } from "./types";
type FileStatusMap = Record<string, Record<string, string[]>>;
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<void> {
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<boolean> {
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<FileStatusMap> {
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<void> {
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<void> {
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);
}
}

View file

@ -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<boolean>;
}

View file

@ -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);
}

View file

@ -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",
);
}

View file

@ -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",
);
}

View file

@ -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<void> {
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;
}

View file

@ -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",
);

View file

@ -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(

View file

@ -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;
}

View file

@ -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(

View file

@ -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() {

View file

@ -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,
}: {