feat: status scoped by template

This commit is contained in:
Aleix Soler 2025-07-20 09:39:50 +02:00
parent 595b76e081
commit ddcbb47bb0
12 changed files with 403 additions and 112 deletions

View file

@ -26,7 +26,7 @@ export const CurrentStatusChips: React.FC<Props> = ({
>
{currentStatuses.map((status) => (
<StatusDisplay
key={status.name}
key={`${status.templateId || "custom"}:${status.name}`}
status={status}
variant="chip"
removable

View file

@ -22,11 +22,16 @@ export const useKeyboardNavigation = ({
const filteredStatuses = useMemo(
() =>
searchFilter
? availableStatuses.filter((status) =>
status.name
.toLowerCase()
.includes(searchFilter.toLowerCase()),
)
? availableStatuses.filter((status) => {
const searchTerm = searchFilter.toLowerCase();
return (
status.name.toLowerCase().includes(searchTerm) ||
(status.templateId &&
status.templateId
.toLowerCase()
.includes(searchTerm))
);
})
: availableStatuses,
[searchFilter, availableStatuses],
);

View file

@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { UISettings } from "./UISettings";
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
import { TemplateSettings } from "./TemplateSettings";
import { BehaviourSettings } from "./BehaviourSettings";
import { CustomStatusSettings } from "./CustomStatusSettings";
import { QuickCommandsSettings } from "./QuickCommandsSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
export type Props = {
settings: PluginSettings;

View file

@ -16,6 +16,12 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
({ status, variant, removable = false, onRemove, onClick }) => {
const [isRemoving, setIsRemoving] = useState(false);
const getDisplayName = () => {
return status.templateId
? `${status.name} (${status.templateId})`
: status.name;
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) {
@ -55,7 +61,9 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
<span className="note-status-chip-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="note-status-chip-text">{status.name}</span>
<span className="note-status-chip-text">
{getDisplayName()}
</span>
{removable && (
<div
className="note-status-chip-remove"
@ -104,7 +112,9 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
<span className="status-badge-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="status-badge-text">{status.name}</span>
<span className="status-badge-text">
{getDisplayName()}
</span>
</div>
</div>
);
@ -122,7 +132,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
}
/>
<span>
{status.icon ? status.icon : "📝"} {status.name}
{status.icon ? status.icon : "📝"} {getDisplayName()}
</span>
</div>
);

View file

@ -12,6 +12,10 @@ interface StatusOptionProps {
export const StatusModalOption: React.FC<StatusOptionProps> = memo(
({ status, isSelected, isFocused, onSelect }) => {
const displayName = status.templateId
? `${status.name} (${status.templateId})`
: status.name;
return (
<SelectableListItem
selected={isSelected}
@ -23,7 +27,7 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
status.description ? getStatusTooltip(status) : undefined
}
>
{status.name}
{displayName}
</SelectableListItem>
);
},
@ -63,7 +67,7 @@ export const StatusSelector: React.FC<Props> = ({
>
{availableStatuses.map((status, index) => (
<StatusModalOption
key={`${status.name}${status.description}${status.color}${status.icon}`}
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
status={status}
isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex}

View file

@ -1,64 +1,5 @@
import { PluginSettings, StatusTemplate } from "types/pluginSettings";
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "colorful",
name: "Colorful workflow",
description:
"A colorful set of workflow statuses with descriptive icons",
statuses: [
{ name: "idea", icon: "💡", color: "#FFEB3B" },
{ name: "draft", icon: "📝", color: "#E0E0E0" },
{ name: "inProgress", icon: "🔧", color: "#FFC107" },
{ name: "editing", icon: "🖊️", color: "#2196F3" },
{ name: "pending", icon: "⏳", color: "#9C27B0" },
{ name: "onHold", icon: "⏸", color: "#9E9E9E" },
{ name: "needsUpdate", icon: "🔄", color: "#FF5722" },
{ name: "completed", icon: "✅", color: "#4CAF50" },
{ name: "archived", icon: "📦", color: "#795548" },
],
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
statuses: [
{ name: "todo", icon: "📌", color: "#F44336" },
{ name: "inProgress", icon: "⚙️", color: "#2196F3" },
{ name: "review", icon: "👀", color: "#9C27B0" },
{ name: "done", icon: "✓", color: "#4CAF50" },
],
},
{
id: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
statuses: [
{ name: "research", icon: "🔍", color: "#2196F3" },
{ name: "outline", icon: "📑", color: "#9E9E9E" },
{ name: "draft", icon: "✏️", color: "#FFC107" },
{ name: "review", icon: "🔬", color: "#9C27B0" },
{ name: "revision", icon: "📝", color: "#FF5722" },
{ name: "final", icon: "📚", color: "#4CAF50" },
{ name: "published", icon: "🎓", color: "#795548" },
],
},
{
id: "project",
name: "Project management",
description: "Status workflow for project management and tracking",
statuses: [
{ name: "planning", icon: "🗓️", color: "#9E9E9E" },
{ name: "backlog", icon: "📋", color: "#E0E0E0" },
{ name: "ready", icon: "🚦", color: "#8BC34A" },
{ name: "inDevelopment", icon: "👨‍💻", color: "#2196F3" },
{ name: "testing", icon: "🧪", color: "#9C27B0" },
{ name: "review", icon: "👁️", color: "#FFC107" },
{ name: "approved", icon: "👍", color: "#4CAF50" },
{ name: "live", icon: "🚀", color: "#3F51B5" },
],
},
];
import { PluginSettings } from "types/pluginSettings";
import { DEFAULT_ENABLED_TEMPLATES } from "./predefinedTemplates";
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
fileExplorerIconPosition: "absolute-right",
@ -74,7 +15,7 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: true, // Default to hide unknown status
enabledTemplates: [PREDEFINED_TEMPLATES[0].id],
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
useCustomStatusesOnly: false,
useMultipleStatuses: true,
tagPrefix: "obsidian-note-status",

View file

@ -0,0 +1,209 @@
import { StatusTemplate } from "types/pluginSettings";
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "colorful",
name: "Colorful workflow",
description:
"A colorful set of workflow statuses with descriptive icons",
statuses: [
{
name: "idea",
icon: "💡",
color: "#FFEB3B",
templateId: "colorful",
},
{
name: "draft",
icon: "📝",
color: "#E0E0E0",
templateId: "colorful",
},
{
name: "inProgress",
icon: "🔧",
color: "#FFC107",
templateId: "colorful",
},
{
name: "editing",
icon: "🖊️",
color: "#2196F3",
templateId: "colorful",
},
{
name: "pending",
icon: "⏳",
color: "#9C27B0",
templateId: "colorful",
},
{
name: "onHold",
icon: "⏸",
color: "#9E9E9E",
templateId: "colorful",
},
{
name: "needsUpdate",
icon: "🔄",
color: "#FF5722",
templateId: "colorful",
},
{
name: "completed",
icon: "✅",
color: "#4CAF50",
templateId: "colorful",
},
{
name: "archived",
icon: "📦",
color: "#795548",
templateId: "colorful",
},
],
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
statuses: [
{
name: "todo",
icon: "📌",
color: "#F44336",
templateId: "minimal",
},
{
name: "inProgress",
icon: "⚙️",
color: "#2196F3",
templateId: "minimal",
},
{
name: "review",
icon: "👀",
color: "#9C27B0",
templateId: "minimal",
},
{
name: "done",
icon: "✓",
color: "#4CAF50",
templateId: "minimal",
},
],
},
{
id: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
statuses: [
{
name: "research",
icon: "🔍",
color: "#2196F3",
templateId: "academic",
},
{
name: "outline",
icon: "📑",
color: "#9E9E9E",
templateId: "academic",
},
{
name: "draft",
icon: "✏️",
color: "#FFC107",
templateId: "academic",
},
{
name: "review",
icon: "🔬",
color: "#9C27B0",
templateId: "academic",
},
{
name: "revision",
icon: "📝",
color: "#FF5722",
templateId: "academic",
},
{
name: "final",
icon: "📚",
color: "#4CAF50",
templateId: "academic",
},
{
name: "published",
icon: "🎓",
color: "#795548",
templateId: "academic",
},
],
},
{
id: "project",
name: "Project management",
description: "Status workflow for project management and tracking",
statuses: [
{
name: "planning",
icon: "🗓️",
color: "#9E9E9E",
templateId: "project",
},
{
name: "backlog",
icon: "📋",
color: "#E0E0E0",
templateId: "project",
},
{
name: "ready",
icon: "🚦",
color: "#8BC34A",
templateId: "project",
},
{
name: "inDevelopment",
icon: "👨‍💻",
color: "#2196F3",
templateId: "project",
},
{
name: "testing",
icon: "🧪",
color: "#9C27B0",
templateId: "project",
},
{
name: "review",
icon: "👁️",
color: "#FFC107",
templateId: "project",
},
{
name: "approved",
icon: "👍",
color: "#4CAF50",
templateId: "project",
},
{
name: "live",
icon: "🚀",
color: "#3F51B5",
templateId: "project",
},
],
},
];
/**
* Default template IDs that should be enabled by default
*/
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];

View file

@ -59,9 +59,7 @@ export class CommandsService {
!settingsService.settings.useMultipleStatuses
) {
const allStatuses =
BaseNoteStatusService.getAllAvailableStatuses().map(
(s) => s.name,
);
BaseNoteStatusService.getAllAvailableStatuses();
if (allStatuses.length === 0) {
new Notice("No statuses available");
@ -80,7 +78,9 @@ export class CommandsService {
: undefined;
let nextIndex = 0;
if (currentStatus) {
const currentIndex = allStatuses.indexOf(currentStatus);
const currentIndex = allStatuses.findIndex(
(s) => s.name === currentStatus,
);
if (currentIndex !== -1) {
nextIndex =
currentIndex === -1
@ -89,15 +89,21 @@ export class CommandsService {
}
}
const nextStatus = allStatuses[nextIndex];
const scopedIdentifier = nextStatus.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: nextStatus.templateId,
name: nextStatus.name,
})
: nextStatus.name;
statusService
.addStatus(
settingsService.settings.tagPrefix,
allStatuses[nextIndex],
scopedIdentifier,
)
.then((resolve) => {
new Notice(
`Status changed to ${allStatuses[nextIndex]}`,
);
new Notice(`Status changed to ${nextStatus.name}`);
});
}
return true;
@ -250,10 +256,17 @@ export class CommandsService {
!settingsService.settings.useMultipleStatuses
) {
const statusService = this.createStatusService(file);
const scopedIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
statusService
.overrideStatuses(
settingsService.settings.tagPrefix,
[statusName],
[scopedIdentifier],
)
.then(() => {
new Notice(`Status set to ${statusName}`);

View file

@ -3,10 +3,12 @@ import {
GroupedStatuses,
NoteStatus,
NoteStatus as NoteStatusType,
StatusIdentifier,
ScopedStatusName,
} from "@/types/noteStatus";
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
import settingsService from "@/core/settingsService";
import eventBus from "./eventBus";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
export abstract class BaseNoteStatusService {
static app: App;
@ -20,6 +22,44 @@ export abstract class BaseNoteStatusService {
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 [];
@ -53,11 +93,13 @@ export abstract class BaseNoteStatusService {
// );
// }
protected statusNameToObject(statusName: NoteStatusType["name"]) {
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const s = availableStatuses.find((f) => f.name === statusName);
return s;
protected statusNameToObject(statusName: string | StatusIdentifier) {
if (typeof statusName === "string") {
return BaseNoteStatusService.resolveStatusFromIdentifier(
statusName,
);
}
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName);
}
protected getStatusMetadataKeys(): string[] {
@ -71,7 +113,7 @@ export abstract class BaseNoteStatusService {
): Promise<boolean>;
abstract addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
statusIdentifier: StatusIdentifier,
): Promise<boolean>;
}
@ -129,6 +171,13 @@ export class NoteStatusService extends BaseNoteStatusService {
status: NoteStatus,
): Promise<boolean> {
let removed = false;
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
@ -138,7 +187,7 @@ export class NoteStatusService extends BaseNoteStatusService {
if (Array.isArray(noteStatusFrontmatter)) {
const i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === status.name,
(statusName: string) => statusName === targetIdentifier,
);
if (i !== -1) {
noteStatusFrontmatter.splice(i, 1);
@ -171,8 +220,13 @@ export class NoteStatusService extends BaseNoteStatusService {
async overrideStatuses(
frontmatterTagName: string,
statusIdentifiers: NoteStatus["name"][],
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
const formattedIdentifiers = statusIdentifiers.map((id) =>
typeof id === "string"
? id
: BaseNoteStatusService.formatStatusIdentifier(id),
);
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
@ -181,9 +235,11 @@ export class NoteStatusService extends BaseNoteStatusService {
Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName].splice(0);
frontmatter[frontmatterTagName].push(...statusIdentifiers);
frontmatter[frontmatterTagName].push(
...formattedIdentifiers,
);
}
frontmatter[frontmatterTagName] = [...statusIdentifiers];
frontmatter[frontmatterTagName] = [...formattedIdentifiers];
},
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
@ -192,16 +248,23 @@ export class NoteStatusService extends BaseNoteStatusService {
async addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
let added = false;
const formattedIdentifier =
typeof statusIdentifier === "string"
? statusIdentifier
: BaseNoteStatusService.formatStatusIdentifier(
statusIdentifier,
);
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!settingsService.settings.useMultipleStatuses) {
frontmatter[frontmatterTagName] = [statusIdentifier];
frontmatter[frontmatterTagName] = [formattedIdentifier];
added = true;
} else {
// Ensure frontmatter property exists as an array
@ -213,10 +276,13 @@ export class NoteStatusService extends BaseNoteStatusService {
}
const i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === statusIdentifier,
(statusName: string) =>
statusName === formattedIdentifier,
);
if (i === -1) {
frontmatter[frontmatterTagName].push(statusIdentifier);
frontmatter[frontmatterTagName].push(
formattedIdentifier,
);
added = true;
}
}
@ -290,6 +356,12 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
status: NoteStatus,
): Promise<boolean> {
let removedFromAny = false;
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
const promises = this.files.map(async (file) => {
let removed = false;
@ -302,13 +374,14 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
if (Array.isArray(noteStatusFrontmatter)) {
const index = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === status.name,
(statusName: string) =>
statusName === targetIdentifier,
);
if (index !== -1) {
noteStatusFrontmatter.splice(index, 1);
removed = true;
}
} else if (noteStatusFrontmatter === status.name) {
} else if (noteStatusFrontmatter === targetIdentifier) {
delete frontmatter[frontmatterTagName];
removed = true;
}
@ -331,9 +404,15 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
async addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
let addedToAny = false;
const formattedIdentifier =
typeof statusIdentifier === "string"
? statusIdentifier
: BaseNoteStatusService.formatStatusIdentifier(
statusIdentifier,
);
const promises = this.files.map(async (file) => {
let added = false;
@ -344,18 +423,20 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
frontmatter[frontmatterTagName];
if (!noteStatusFrontmatter) {
frontmatter[frontmatterTagName] = [statusIdentifier];
frontmatter[frontmatterTagName] = [formattedIdentifier];
added = true;
} else if (Array.isArray(noteStatusFrontmatter)) {
if (!noteStatusFrontmatter.includes(statusIdentifier)) {
noteStatusFrontmatter.push(statusIdentifier);
if (
!noteStatusFrontmatter.includes(formattedIdentifier)
) {
noteStatusFrontmatter.push(formattedIdentifier);
added = true;
}
} else {
if (noteStatusFrontmatter !== statusIdentifier) {
if (noteStatusFrontmatter !== formattedIdentifier) {
frontmatter[frontmatterTagName] = [
noteStatusFrontmatter,
statusIdentifier,
formattedIdentifier,
];
added = true;
}
@ -381,6 +462,13 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
frontmatterTagName: string,
status: NoteStatus,
): TFile[] {
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
return this.files.filter((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
@ -391,9 +479,9 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
if (!value) return false;
if (Array.isArray(value)) {
return value.includes(status.name);
return value.includes(targetIdentifier);
}
return value === status.name;
return value === targetIdentifier;
});
}
}

View file

@ -8,6 +8,7 @@ import SelectorService from "@/core/selectorService";
import {
MultipleNoteStatusService,
NoteStatusService,
BaseNoteStatusService,
} from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
@ -69,9 +70,16 @@ export class StatusModalIntegration extends Modal {
frontmatterTagName,
status,
) => {
const scopedIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
const added = await this.selectorService.noteStatusService.addStatus(
frontmatterTagName,
status.name,
scopedIdentifier,
);
if (added) {
new Notice(`Status ${status.name} added successfully to the note`);

View file

@ -3,7 +3,15 @@ export type NoteStatus = {
icon: string;
color?: string; // Optional color property
description?: string; // Optional description property
templateId?: string; // Optional template scope for namespacing
[key: string]: unknown;
};
export type ScopedStatusName = {
templateId?: string;
name: string;
};
export type StatusIdentifier = string | ScopedStatusName;
export type GroupedStatuses = Record<string, NoteStatus[]>;

View file

@ -8,7 +8,9 @@ export const isStatusSelected = (
status: NoteStatus,
currentStatuses: NoteStatus[],
): boolean => {
return currentStatuses.some((s) => s.name === status.name);
return currentStatuses.some(
(s) => s.name === status.name && s.templateId === status.templateId,
);
};
/**
@ -49,13 +51,16 @@ export const getStatusDisplayName = (
};
/**
* Compares two statuses for equality by name
* Compares two statuses for equality by name and templateId
*/
export const isStatusEqual = (
status1: NoteStatus,
status2: NoteStatus,
): boolean => {
return status1.name === status2.name;
return (
status1.name === status2.name &&
status1.templateId === status2.templateId
);
};
/**