feat: fronttmatter tag by template

This commit is contained in:
Aleix Soler 2025-11-21 17:10:10 +01:00
parent 3cfd46b848
commit 754d5002d4
16 changed files with 864 additions and 115 deletions

View file

@ -1,6 +1,7 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { SettingItem } from "./SettingItem";
import { FrontmatterMappingsSettings } from "./FrontmatterMappingsSettings";
export type Props = {
settings: PluginSettings;
@ -91,6 +92,17 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
/>
</SettingItem>
<SettingItem
name="Frontmatter mappings"
description="Map templates or individual statuses to specific YAML keys. These mappings only apply to Markdown files with frontmatter."
vertical
>
<FrontmatterMappingsSettings
settings={settings}
onChange={onChange}
/>
</SettingItem>
<SettingItem
name="Vault size limit"
description="Disable dashboard and grouped view for vaults with more notes than this limit to improve performance. Set to 0 to disable this feature."

View file

@ -0,0 +1,315 @@
import React, { useMemo } from "react";
import {
PluginSettings,
StatusFrontmatterMapping,
} from "@/types/pluginSettings";
type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
type StatusOption = {
label: string;
value: string;
};
const isStatusMapping = (
mapping: StatusFrontmatterMapping,
): mapping is StatusFrontmatterMapping & { scope: "status" } =>
mapping.scope === "status";
const generateId = () =>
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const sanitizeKeys = (raw: string): string[] =>
raw
.split(",")
.map((key) => key.trim())
.filter((key) => Boolean(key));
const buildStatusIdentifier = (
templateId: string | undefined,
statusName: string,
): string => {
if (!templateId) {
return statusName;
}
return `${templateId}:${statusName}`;
};
export const FrontmatterMappingsSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const mappings = settings.statusFrontmatterMappings || [];
const templateOptions = useMemo(
() =>
(settings.templates || []).map((template) => ({
value: template.id,
label: template.name || template.id,
})),
[settings.templates],
);
const statusOptions = useMemo<StatusOption[]>(() => {
const templateStatuses =
settings.templates?.flatMap((template) =>
template.statuses.map((status) => ({
value: buildStatusIdentifier(template.id, status.name),
label: `${status.name} (${template.name || template.id})`,
})),
) || [];
const customStatuses =
settings.customStatuses?.map((status) => ({
value: status.name,
label: `${status.name} (Custom)`,
})) || [];
return [...templateStatuses, ...customStatuses];
}, [settings.templates, settings.customStatuses]);
const updateMappings = (nextMappings: StatusFrontmatterMapping[]) => {
onChange("statusFrontmatterMappings", nextMappings);
};
const handleMappingChange = (
id: string,
updater: (
mapping: StatusFrontmatterMapping,
) => StatusFrontmatterMapping,
) => {
updateMappings(
mappings.map((mapping) =>
mapping.id === id ? updater(mapping) : mapping,
),
);
};
const handleScopeChange = (
id: string,
scope: StatusFrontmatterMapping["scope"],
) => {
handleMappingChange(id, (mapping) => {
const frontmatterKeys = mapping.frontmatterKeys || [];
if (scope === "template") {
const templateId =
mapping.scope === "template"
? mapping.templateId
: templateOptions[0]?.value || "";
return {
id: mapping.id,
scope: "template",
templateId,
frontmatterKeys,
};
}
const defaultStatusOption = statusOptions[0];
const templateId = isStatusMapping(mapping)
? mapping.templateId
: defaultStatusOption?.value.includes(":")
? defaultStatusOption.value.split(":", 2)[0]
: undefined;
const statusName = isStatusMapping(mapping)
? mapping.statusName || ""
: defaultStatusOption?.value?.split(":")?.pop() || "";
return {
id: mapping.id,
scope: "status",
templateId,
statusName,
frontmatterKeys,
};
});
};
const handleTemplateChange = (id: string, templateId: string) => {
handleMappingChange(id, (mapping) => ({
...mapping,
scope: "template",
templateId,
}));
};
const handleStatusChange = (id: string, identifier: string) => {
const [maybeTemplateId, name] = identifier.includes(":")
? identifier.split(":", 2)
: [undefined, identifier];
handleMappingChange(id, (mapping) => ({
...mapping,
templateId: maybeTemplateId,
statusName: name,
scope: "status",
}));
};
const handleKeysChange = (id: string, raw: string) => {
const keys = sanitizeKeys(raw);
handleMappingChange(id, (mapping) => ({
...mapping,
frontmatterKeys: keys,
}));
};
const handleRemove = (id: string) => {
updateMappings(mappings.filter((mapping) => mapping.id !== id));
};
const handleAdd = () => {
const newMapping: StatusFrontmatterMapping = {
id: generateId(),
scope: "template",
templateId: templateOptions[0]?.value || "",
frontmatterKeys: [],
};
updateMappings([...mappings, newMapping]);
};
return (
<div className="status-mapping-list">
{mappings.length === 0 && (
<div className="status-mapping-empty">
No mappings configured yet.
</div>
)}
{mappings.map((mapping) => {
const currentKeys = mapping.frontmatterKeys || [];
const keysValue = currentKeys.join(", ");
const selectedStatus =
mapping.scope === "status"
? buildStatusIdentifier(
mapping.templateId,
mapping.statusName || "",
)
: undefined;
return (
<div className="status-mapping-row" key={mapping.id}>
<div className="status-mapping-row__fields">
<div className="status-mapping-field">
<label>Target type</label>
<select
value={mapping.scope}
onChange={(event) =>
handleScopeChange(
mapping.id,
event.target
.value as StatusFrontmatterMapping["scope"],
)
}
>
<option value="template">Template</option>
<option value="status">
Specific status
</option>
</select>
</div>
{mapping.scope === "template" ? (
<div className="status-mapping-field">
<label>Template</label>
<select
value={mapping.templateId || ""}
onChange={(event) =>
handleTemplateChange(
mapping.id,
event.target.value,
)
}
>
{templateOptions.length === 0 && (
<option value="">
No templates available
</option>
)}
{templateOptions.map((template) => (
<option
value={template.value}
key={template.value}
>
{template.label}
</option>
))}
</select>
</div>
) : (
<div className="status-mapping-field">
<label>Status</label>
<select
value={selectedStatus || ""}
onChange={(event) =>
handleStatusChange(
mapping.id,
event.target.value,
)
}
>
{statusOptions.length === 0 && (
<option value="">
No statuses available
</option>
)}
{statusOptions.map((option) => (
<option
value={option.value}
key={option.value}
>
{option.label}
</option>
))}
</select>
</div>
)}
<div className="status-mapping-field status-mapping-field--keys">
<label>Frontmatter keys</label>
<input
type="text"
placeholder="status, project-status"
value={keysValue}
onChange={(event) =>
handleKeysChange(
mapping.id,
event.target.value,
)
}
/>
<small>
Separate multiple keys with commas. Include
the default key if you still want to write
to it.
</small>
</div>
</div>
<div className="status-mapping-row__actions">
<button
type="button"
className="status-mapping-remove-btn"
onClick={() => handleRemove(mapping.id)}
>
Remove
</button>
</div>
</div>
);
})}
<button
type="button"
className="status-mapping-add-btn"
onClick={handleAdd}
>
Add mapping
</button>
</div>
);
};

View file

@ -6,6 +6,7 @@ import {
NoteStatusService,
} from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
export interface VaultStats {
totalNotes: number;
@ -33,7 +34,9 @@ export const useVaultStats = () => {
const files = BaseNoteStatusService.app.vault.getFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const statusMetadataKeys = getKnownFrontmatterKeys(
settingsService.settings,
);
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};

View file

@ -51,4 +51,5 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
editorToolbarButtonPosition: "right", // Default position on the right
editorToolbarButtonDisplay: "all-notes", // Default to show button in all notes
applyStatusRecursivelyToSubfolders: false,
statusFrontmatterMappings: [],
};

View file

@ -4,6 +4,8 @@ import settingsService from "./settingsService";
import eventBus from "./eventBus";
import { VIEW_TYPE_EXAMPLE } from "../integrations/views/grouped-status-view";
import { isExperimentalFeatureEnabled } from "@/utils/experimentalFeatures";
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
export class CommandsService {
private plugin: Plugin;
@ -71,23 +73,20 @@ export class CommandsService {
// Get the current file data
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const statuses =
statusService.statuses[
settingsService.settings.tagPrefix
] || [];
const currentStatus = statuses.length
? statuses?.[0].name
: undefined;
const flattened = this.flattenStatuses(
statusService.statuses,
);
const currentStatus = flattened[0];
let nextIndex = 0;
if (currentStatus) {
const currentIndex = allStatuses.findIndex(
(s) => s.name === currentStatus,
(s) =>
s.name === currentStatus.name &&
(s.templateId || null) ===
(currentStatus.templateId || null),
);
if (currentIndex !== -1) {
nextIndex =
currentIndex === -1
? 0
: (currentIndex + 1) % allStatuses.length;
nextIndex = (currentIndex + 1) % allStatuses.length;
}
}
@ -214,10 +213,17 @@ export class CommandsService {
if (!statuses || statuses.length === 0) return false;
const tagPrefix = settingsService.settings.tagPrefix;
const queries = statuses.map(
(status) => `[${tagPrefix}:"${status}"]`,
const keys = getKnownFrontmatterKeys(
settingsService.settings,
);
const queries = Array.from(
new Set(
statuses.flatMap((status) =>
keys.map((key) => `[${key}:"${status}"]`),
),
),
);
if (!queries.length) return false;
const query = queries.join(" OR ");
// @ts-ignore
@ -340,12 +346,38 @@ export class CommandsService {
});
}
private flattenStatuses(
groupedStatuses: GroupedStatuses | null | undefined,
): NoteStatus[] {
if (!groupedStatuses) {
return [];
}
const seen = new Set<string>();
const flattened: NoteStatus[] = [];
Object.values(groupedStatuses).forEach((list) => {
list.forEach((status) => {
const identifier = BaseNoteStatusService.formatStatusIdentifier(
{
templateId: status.templateId,
name: status.name,
},
);
if (!seen.has(identifier)) {
seen.add(identifier);
flattened.push(status);
}
});
});
return flattened;
}
private getFileStatuses(file: TFile): string[] {
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const tagPrefix = settingsService.settings.tagPrefix;
const statuses = statusService.statuses[tagPrefix] || [];
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
const flattened = this.flattenStatuses(statusService.statuses);
return flattened.length > 0
? flattened.map((s) => s.name)
: ["unknown"];
}
/**

View file

@ -10,6 +10,10 @@ import settingsService from "@/core/settingsService";
import eventBus from "./eventBus";
import statusStoreManager from "./statusStoreManager";
import type { StatusStore } from "./statusStores/types";
import {
getKnownFrontmatterKeys,
resolveFrontmatterKeysForStatus,
} from "@/utils/frontmatterMappings";
export abstract class BaseNoteStatusService {
static app: App;
@ -129,6 +133,39 @@ export class NoteStatusService extends BaseNoteStatusService {
this.statusStore = statusStoreManager.getStoreForFile(file);
}
private isMarkdownFile(): boolean {
return this.file.extension === "md";
}
private getKeysForReading(): string[] {
const defaultKey = settingsService.settings.tagPrefix;
if (!this.isMarkdownFile()) {
return [defaultKey];
}
const knownKeys = getKnownFrontmatterKeys(settingsService.settings);
return [defaultKey, ...knownKeys.filter((key) => key !== defaultKey)];
}
private collectIdentifiersForFile(): string[] {
const keysToRead = this.getKeysForReading();
const seen = new Set<string>();
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 shouldStoreStatusesAsArray(): boolean {
if (settingsService.settings.useMultipleStatuses) {
return true;
@ -141,48 +178,104 @@ export class NoteStatusService extends BaseNoteStatusService {
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;
if (
resolved.length === 1 &&
resolved[0] === defaultKey &&
frontmatterTagName !== defaultKey
) {
resolved = [frontmatterTagName];
}
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<boolean>,
): Promise<boolean> {
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 STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
const defaultKey = settingsService.settings.tagPrefix;
this.statuses = { [defaultKey]: [] };
if (!this.file) {
return;
}
STATUS_METADATA_KEYS.forEach((key) => {
const identifiers = this.statusStore.getStatuses(this.file, key);
if (!identifiers.length) {
return;
}
const identifiers = this.collectIdentifiersForFile();
if (!identifiers.length) {
return;
}
let statuses = identifiers
.map((identifier) =>
this.statusNameToObject(identifier.toString()),
)
.filter(
(status): status is NoteStatusType => status !== undefined,
);
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);
}
this.statuses[key] = statuses;
});
if (statuses.length && !settingsService.settings.useMultipleStatuses) {
statuses = statuses.slice(0, 1);
}
this.statuses[defaultKey] = statuses;
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
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<boolean> {
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
@ -191,7 +284,7 @@ export class NoteStatusService extends BaseNoteStatusService {
})
: status.name;
const removed = await this.statusStore.mutateStatuses(
return this.statusStore.mutateStatuses(
this.file,
frontmatterTagName,
(current) => {
@ -219,17 +312,29 @@ export class NoteStatusService extends BaseNoteStatusService {
},
this.getStoreOptions(),
);
}
if (removed) {
async clearStatus(frontmatterTagName: string): Promise<boolean> {
const keys = new Set<string>([frontmatterTagName]);
if (this.isMarkdownFile()) {
this.getKeysForReading().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 removed;
return cleared;
}
async clearStatus(frontmatterTagName: string): Promise<boolean> {
const cleared = await this.statusStore.mutateStatuses(
private async clearStatusForKey(
frontmatterTagName: string,
): Promise<boolean> {
return this.statusStore.mutateStatuses(
this.file,
frontmatterTagName,
(current) => {
@ -240,37 +345,50 @@ export class NoteStatusService extends BaseNoteStatusService {
},
this.getStoreOptions(),
);
if (cleared) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return cleared;
}
async overrideStatuses(
frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
const formattedIdentifiers = statusIdentifiers.map((id) =>
typeof id === "string"
? id
: BaseNoteStatusService.formatStatusIdentifier(id),
);
const statusesToStore = settingsService.settings.useMultipleStatuses
? formattedIdentifiers
: formattedIdentifiers.slice(0, 1);
const limitedIdentifiers = settingsService.settings.useMultipleStatuses
? statusIdentifiers
: statusIdentifiers.slice(0, 1);
const overridden = await this.statusStore.mutateStatuses(
this.file,
frontmatterTagName,
(current) => {
if (this.statusesAreEqual(current, statusesToStore)) {
return { hasChanged: false };
if (!limitedIdentifiers.length) {
return this.clearStatus(frontmatterTagName);
}
const keyMap = new Map<string, StatusIdentifier[]>();
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,
...this.getStatusMetadataKeys(),
]);
knownKeys.forEach((key) => {
if (!keyMap.has(key)) {
keyMap.set(key, []);
}
return { hasChanged: true, nextStatuses: statusesToStore };
},
this.getStoreOptions(),
});
}
const overridden = await this.runAcrossKeys(
Array.from(keyMap.keys()),
(key) => this.overrideStatusesForKey(key, keyMap.get(key) ?? []),
);
if (overridden) {
@ -281,9 +399,55 @@ export class NoteStatusService extends BaseNoteStatusService {
return overridden;
}
private async overrideStatusesForKey(
frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
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<boolean> {
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<boolean> {
const formattedIdentifier =
typeof statusIdentifier === "string"
@ -292,7 +456,7 @@ export class NoteStatusService extends BaseNoteStatusService {
statusIdentifier,
);
const added = await this.statusStore.mutateStatuses(
return this.statusStore.mutateStatuses(
this.file,
frontmatterTagName,
(current) => {
@ -334,13 +498,6 @@ export class NoteStatusService extends BaseNoteStatusService {
},
this.getStoreOptions(),
);
if (added) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return added;
}
}
@ -356,14 +513,20 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
return this.files.length;
}
private getKeysForReading(file: TFile): string[] {
const defaultKey = settingsService.settings.tagPrefix;
if (file.extension !== "md") {
return [defaultKey];
}
const knownKeys = getKnownFrontmatterKeys(settingsService.settings);
return [defaultKey, ...knownKeys.filter((key) => key !== defaultKey)];
}
public populateStatuses(): void {
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
const defaultKey = settingsService.settings.tagPrefix;
this.statuses = { [defaultKey]: [] };
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
const allStatuses = new Map<string, Set<string>>();
const aggregated = new Set<string>();
this.files.forEach((file) => {
let store: StatusStore;
@ -373,30 +536,27 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
return;
}
STATUS_METADATA_KEYS.forEach((key) => {
const keys = this.getKeysForReading(file);
keys.forEach((key) => {
const identifiers = store.getStatuses(file, key);
if (!identifiers.length) return;
if (!allStatuses.has(key)) {
allStatuses.set(key, new Set());
}
identifiers.forEach((name) =>
allStatuses.get(key)!.add(name.toString()),
);
identifiers.forEach((identifier) => {
const normalized = identifier?.toString();
if (normalized && !aggregated.has(normalized)) {
aggregated.add(normalized);
}
});
});
});
STATUS_METADATA_KEYS.forEach((key) => {
const statusNames = allStatuses.get(key);
if (statusNames) {
const statuses = Array.from(statusNames)
.map((name) => this.statusNameToObject(name))
.filter((s) => s !== undefined) as NoteStatusType[];
if (!aggregated.size) {
return;
}
this.statuses[key] = statuses;
}
});
const statuses = Array.from(aggregated)
.map((identifier) => this.statusNameToObject(identifier))
.filter((status): status is NoteStatusType => status !== undefined);
this.statuses[defaultKey] = statuses;
}
async removeStatus(

View file

@ -68,6 +68,7 @@ export class FileExplorerIntegration implements IElementProcessor {
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "tagPrefix" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" ||
key === "fileExplorerIconPosition" ||
key === "fileExplorerIconFrame" ||

View file

@ -50,6 +50,9 @@ export class StatusBarIntegration {
if (key === "tagPrefix") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "statusFrontmatterMappings") {
this.handleActiveFileChange().catch(console.error);
}
if (key === "useCustomStatusesOnly") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}

View file

@ -71,6 +71,9 @@ export class EditorToolbarIntegration {
if (key === "tagPrefix") {
this.refreshAllButtons();
}
if (key === "statusFrontmatterMappings") {
this.refreshAllButtons();
}
if (key === "useCustomStatusesOnly") {
this.refreshAllButtons();
}

View file

@ -8,6 +8,7 @@ import {
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view";
@ -71,7 +72,9 @@ export class GroupedDashboardView extends ItemView {
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const statusMetadataKeys = getKnownFrontmatterKeys(
settingsService.settings,
);
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
@ -147,6 +150,7 @@ export class GroupedDashboardView extends ItemView {
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses"
) {
onDataChange();

View file

@ -13,6 +13,7 @@ import {
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
export const VIEW_TYPE_EXAMPLE = "grouped-status-view";
@ -55,7 +56,9 @@ export class GroupedStatusView extends ItemView {
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const statusMetadataKeys = getKnownFrontmatterKeys(
settingsService.settings,
);
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
@ -131,6 +134,7 @@ export class GroupedStatusView extends ItemView {
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" ||
key === "vaultSizeLimit"
) {

View file

@ -10,6 +10,7 @@ import settingsService from "@/core/settingsService";
import eventBus from "@/core/eventBus";
import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
import { NoteStatus } from "@/types/noteStatus";
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
interface AppWithCommands extends App {
commands: {
@ -61,7 +62,9 @@ export class StatusDashboardView extends ItemView {
const files = this.app.vault.getFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const statusMetadataKeys = getKnownFrontmatterKeys(
settingsService.settings,
);
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
@ -224,12 +227,19 @@ export class StatusDashboardView extends ItemView {
private findUnassignedNotes() {
const files = this.app.vault.getMarkdownFiles();
const statusKeys = getKnownFrontmatterKeys(settingsService.settings);
const filesWithoutStatus = files.filter((file) => {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
return (
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
);
if (!frontmatter) return true;
return !statusKeys.some((key) => {
const value = frontmatter[key];
if (Array.isArray(value)) {
return value.length > 0;
}
return Boolean(value);
});
});
if (filesWithoutStatus.length === 0) {
@ -242,8 +252,7 @@ export class StatusDashboardView extends ItemView {
);
// Create a search query to find files without the status tag
const tagPrefix = settingsService.settings.tagPrefix;
const query = `-[${tagPrefix}:]`;
const query = statusKeys.map((key) => `-[${key}:]`).join(" ");
// @ts-ignore
this.app.internalPlugins.plugins[
@ -252,8 +261,10 @@ export class StatusDashboardView extends ItemView {
}
private searchBySpecificStatus(statusName: string) {
const tagPrefix = settingsService.settings.tagPrefix;
const query = `[${tagPrefix}:"${statusName}"]`;
const keys = getKnownFrontmatterKeys(settingsService.settings);
const query = keys
.map((key) => `[${key}:"${statusName}"]`)
.join(" OR ");
// @ts-ignore
this.app.internalPlugins.plugins[
@ -325,6 +336,7 @@ export class StatusDashboardView extends ItemView {
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "strictStatuses" ||
key === "statusFrontmatterMappings" ||
key === "vaultSizeLimit"
) {
handleVaultChange();

File diff suppressed because one or more lines are too long

View file

@ -350,6 +350,78 @@
border-radius: var(--radius-s);
background: var(--background-primary);
}
.status-mapping-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
width: 100%;
}
.status-mapping-row {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-3);
background: var(--background-primary-alt);
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.status-mapping-empty {
padding: var(--size-4-3);
text-align: center;
color: var(--text-muted);
font-style: italic;
border: 1px dashed var(--background-modifier-border);
border-radius: var(--radius-m);
}
.status-mapping-row__fields {
display: flex;
flex-wrap: wrap;
gap: var(--size-4-2);
width: 100%;
}
.status-mapping-field {
display: flex;
flex-direction: column;
gap: var(--size-2-1);
flex: 1 1 180px;
}
.status-mapping-field label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.status-mapping-field small {
color: var(--text-faint);
}
.status-mapping-field select,
.status-mapping-field input {
width: 100%;
}
.status-mapping-row__actions {
display: flex;
justify-content: flex-end;
}
.status-mapping-remove-btn {
background: var(--background-modifier-error);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-s);
padding: 4px 10px;
cursor: pointer;
}
.status-mapping-remove-btn:hover {
opacity: 0.9;
}
.status-mapping-add-btn {
align-self: flex-start;
padding: var(--size-2-2) var(--size-4-2);
border-radius: var(--radius-s);
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
cursor: pointer;
}
.status-mapping-add-btn:hover {
background: var(--interactive-hover);
}
.note-status-option {
/* Uses SelectableListItem styles */

View file

@ -1,5 +1,20 @@
import { NoteStatus } from "./noteStatus";
export type StatusFrontmatterMapping =
| {
id: string;
scope: "template";
templateId: string;
frontmatterKeys: string[];
}
| {
id: string;
scope: "status";
templateId?: string;
statusName: string;
frontmatterKeys: string[];
};
export interface StatusTemplate {
id: string;
name: string;
@ -51,5 +66,6 @@ export type PluginSettings = {
editorToolbarButtonPosition: "left" | "right" | "right-before"; // Position of the toolbar button
editorToolbarButtonDisplay: "all-notes" | "active-only"; // Whether to show button in all notes or only active one
applyStatusRecursivelyToSubfolders: boolean; // Whether to show recursive folder context option
statusFrontmatterMappings: StatusFrontmatterMapping[]; // Custom mappings between templates/statuses and frontmatter keys
[key: string]: unknown;
};

View file

@ -0,0 +1,111 @@
import {
PluginSettings,
StatusFrontmatterMapping,
} from "@/types/pluginSettings";
import {
NoteStatus,
ScopedStatusName,
StatusIdentifier,
} from "@/types/noteStatus";
type StatusLike = NoteStatus | ScopedStatusName | StatusIdentifier;
const normalizeKeys = (keys: string[]): string[] => {
const unique = new Set(
keys
.map((key) => key?.trim())
.filter((key): key is string => Boolean(key)),
);
return Array.from(unique);
};
const toScopedStatusName = (status: StatusLike): ScopedStatusName => {
if (typeof status === "string") {
if (status.includes(":")) {
const [templateId, name] = status.split(":", 2);
return { templateId, name };
}
return { name: status };
}
if ("name" in status) {
return {
name: status.name,
templateId: "templateId" in status ? status.templateId : undefined,
};
}
return status;
};
const matchesStatusMapping = (
mapping: StatusFrontmatterMapping,
status: ScopedStatusName,
) => {
if (mapping.scope === "status") {
const templateMatches =
(mapping.templateId || null) === (status.templateId || null);
return templateMatches && mapping.statusName === status.name;
}
if (mapping.scope === "template") {
return Boolean(status.templateId) &&
status.templateId === mapping.templateId
? true
: false;
}
return false;
};
const getMappingKeys = (mapping: StatusFrontmatterMapping): string[] => {
return normalizeKeys(mapping.frontmatterKeys || []);
};
export const getKnownFrontmatterKeys = (settings: PluginSettings): string[] => {
const keys = [
settings.tagPrefix,
...(settings.statusFrontmatterMappings ?? []).flatMap((mapping) =>
getMappingKeys(mapping),
),
];
return normalizeKeys(keys);
};
export const resolveFrontmatterKeysForStatus = (
status: StatusLike,
settings: PluginSettings,
options?: { isMarkdownFile?: boolean },
): string[] => {
if (options?.isMarkdownFile === false) {
return [settings.tagPrefix];
}
const scoped = toScopedStatusName(status);
const mappings = settings.statusFrontmatterMappings ?? [];
const statusMapping = mappings.find(
(mapping) =>
mapping.scope === "status" && matchesStatusMapping(mapping, scoped),
);
if (statusMapping) {
const keys = getMappingKeys(statusMapping);
if (keys.length) {
return keys;
}
}
const templateMapping = mappings.find(
(mapping) =>
mapping.scope === "template" &&
matchesStatusMapping(mapping, scoped),
);
if (templateMapping) {
const keys = getMappingKeys(templateMapping);
if (keys.length) {
return keys;
}
}
return [settings.tagPrefix];
};