Merge pull request #91 from devonthesofa/feature/templates-marketplace

Templates marketplace
This commit is contained in:
Aleix Soler 2026-03-18 19:43:04 +01:00 committed by GitHub
commit 4001c7bd9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1474 additions and 223 deletions

View file

@ -73,6 +73,39 @@ It works on both Markdown and non-Markdown files, integrates into multiple UI su
- ![status-dashboard-2](images/status-dashboard-2.png)
- ![status-dashboard-3](images/status-dashboard-3.png)
## Template Marketplace
You can contribute your own status templates to the plugin!
### How to contribute (The Easy Way)
1. Create a custom template in the plugin settings.
2. Click the **Share** (📤) icon on your custom template.
3. Follow the instructions in the modal to copy the JSON and submit a Pull Request to the [official repository](https://github.com/devonthesofa/obsidian-note-status).
4. Maintainers will review your submission and accept it if it passes the revision!
### How to contribute (Manual)
1. Fork the repository.
...
2. Create a new JSON file in the `templates/` folder (e.g., `templates/my-awesome-workflow.json`).
3. Follow this format:
```json
{
"id": "my-awesome-workflow",
"name": "My Awesome Workflow",
"description": "A workflow for doing awesome things",
"authorGithub": "your-username",
"statuses": [
{ "name": "todo", "icon": "📝", "color": "#ff0000", "templateId": "my-awesome-workflow" },
{ "name": "done", "icon": "✅", "color": "#00ff00", "templateId": "my-awesome-workflow" }
]
}
```
4. Submit a Pull Request with your JSON file!
Once accepted and merged, your template will be automatically included in the next build and available in the marketplace for all users.
## Installation
### Community Plugin Store (recommended)

View file

@ -0,0 +1,131 @@
import React, { useState, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
import { SearchFilter } from "../atoms/SearchFilter";
interface MarketplaceBrowseModalProps {
installedTemplates: StatusTemplate[];
onInstall: (template: StatusTemplate) => void;
onClose: () => void;
}
export const MarketplaceBrowseModal: React.FC<MarketplaceBrowseModalProps> = ({
installedTemplates,
onInstall,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const filteredTemplates = useMemo(() => {
return PREDEFINED_TEMPLATES.filter(
(t) =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return (
<div className="template-editor-modal marketplace-browse-modal">
<div className="template-editor-modal__header">
<div className="marketplace-header-content">
<h2>Template Marketplace</h2>
<SearchFilter
value={searchQuery}
onFilterChange={setSearchQuery}
placeholder="Search templates..."
/>
</div>
</div>
<div className="template-editor-modal__content">
<div className="marketplace-grid">
{filteredTemplates.map((template) => {
const isInstalled = installedTemplates.some(
(t) =>
t.name.toLowerCase() ===
template.name.toLowerCase(),
);
return (
<div key={template.id} className="marketplace-card">
<div className="marketplace-card-header">
<div className="marketplace-card-title">
{template.name}
</div>
<div className="marketplace-card-meta">
<span className="template-badge badge-info">
Marketplace
</span>
{template.authorGithub && (
<div className="marketplace-card-author">
by{" "}
<a
href={`https://github.com/${template.authorGithub}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) =>
e.stopPropagation()
}
>
@{template.authorGithub}
</a>
</div>
)}
</div>
</div>
<div className="marketplace-card-description">
{template.description}
</div>
<div className="marketplace-card-statuses">
{template.statuses.map((status, idx) => (
<StatusDisplay
key={idx}
status={status}
variant="template"
/>
))}
</div>
<div className="marketplace-card-actions">
<button
className={`marketplace-install-btn ${
isInstalled
? "installed"
: "mod-cta"
}`}
onClick={() =>
!isInstalled && onInstall(template)
}
disabled={isInstalled}
>
<ObsidianIcon
name={
isInstalled
? "check"
: "download"
}
size={16}
/>
{isInstalled
? "Installed"
: "Install Template"}
</button>
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<div className="marketplace-empty">
<ObsidianIcon name="search-slash" size={48} />
<p>No templates found matching your search.</p>
</div>
)}
</div>
<div className="template-editor-modal__actions">
<button onClick={onClose}>Close</button>
</div>
</div>
);
};

View file

@ -0,0 +1,107 @@
import React, { useState, useCallback } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
interface MarketplaceShareModalProps {
template: StatusTemplate;
onClose: () => void;
}
export const MarketplaceShareModal: React.FC<MarketplaceShareModalProps> = ({
template,
onClose,
}) => {
const [copied, setCopied] = useState(false);
const marketplaceTemplate = {
...template,
id: template.id.replace(/^custom-/, ""),
statuses: template.statuses.map((s) => ({
...s,
templateId: template.id.replace(/^custom-/, ""),
})),
};
const jsonString = JSON.stringify(marketplaceTemplate, null, 2);
const filename = `${marketplaceTemplate.id}.json`;
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [jsonString]);
return (
<div className="template-editor-modal marketplace-share-modal">
<div className="template-editor-modal__header">
<h2>Submit to Marketplace</h2>
</div>
<div className="template-editor-modal__content">
<p>
Share your template with the community by following these
steps:
</p>
<ol className="marketplace-steps">
<li>
<strong>Copy</strong> the template JSON data below.
</li>
<li>
Go to the <strong>templates folder</strong> in the
official repository:
<br />
<a
href="https://github.com/devonthesofa/obsidian-note-status/tree/master/templates"
target="_blank"
rel="noopener noreferrer"
>
github.com/devonthesofa/obsidian-note-status/templates
</a>
</li>
<li>
Click <strong>Add file</strong> &gt;{" "}
<strong>Create new file</strong>.
</li>
<li>
Name it <code>{filename}</code> and{" "}
<strong>paste</strong> the content.
</li>
<li>
Click <strong>Propose new file</strong> and submit the
Pull Request.
</li>
</ol>
<p className="marketplace-note">
<em>
Note: Submissions are reviewed by maintainers. Once
verified, your template will be included in the next
plugin update for all users!
</em>
</p>
<div className="marketplace-json-preview">
<div className="json-preview-header">
<span>{filename}</span>
<button
className={`copy-btn ${copied ? "success" : ""}`}
onClick={handleCopy}
>
<ObsidianIcon
name={copied ? "check" : "copy"}
size={14}
/>
{copied ? "Copied!" : "Copy JSON"}
</button>
</div>
<pre>{jsonString}</pre>
</div>
</div>
<div className="template-editor-modal__actions">
<button onClick={onClose}>Close</button>
</div>
</div>
);
};

View file

@ -1,9 +1,10 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { NoteStatus } from "@/types/noteStatus";
import { Input } from "@/components/atoms/Input";
import { SettingItem } from "./SettingItem";
import { CustomStatusItem } from "./CustomStatusItem";
import { isCustomTemplate } from "@/utils/templateUtils";
interface TemplateEditorModalProps {
template?: StatusTemplate;
@ -16,8 +17,15 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
onSave,
onCancel,
}) => {
const isCustom = useMemo(
() => (template ? isCustomTemplate(template) : true),
[template],
);
const [name, setName] = useState(template?.name || "");
const [description, setDescription] = useState(template?.description || "");
const [authorGithub, setAuthorGithub] = useState(
template?.authorGithub || "",
);
const [statuses, setStatuses] = useState<NoteStatus[]>(
template?.statuses || [
{
@ -52,6 +60,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
id: templateId,
name: name.trim(),
description: description.trim(),
authorGithub: authorGithub.trim() || undefined,
statuses: statusesWithTemplateId,
};
@ -145,6 +154,23 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
/>
</SettingItem>
<SettingItem
name="Author GitHub Username (Optional)"
description={
isCustom
? "Your GitHub username for attribution"
: "Original author of the marketplace template"
}
>
<Input
variant="text"
value={authorGithub}
onChange={setAuthorGithub}
placeholder="e.g. janedoe"
disabled={!isCustom}
/>
</SettingItem>
<SettingItem
name="Statuses"
description="Define the statuses included in this template, including emojis and optional Lucide icons"

View file

@ -1,8 +1,8 @@
import React from "react";
import React, { useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SelectableListItem } from "../atoms/SelectableListItem";
import { isCustomTemplate } from "@/utils/templateUtils";
import { isCustomTemplate, isTemplateModified } from "@/utils/templateUtils";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
interface TemplateItemProps {
@ -11,6 +11,7 @@ interface TemplateItemProps {
onToggle: (templateId: string, enabled: boolean) => void;
onEdit?: (template: StatusTemplate) => void;
onDelete?: (templateId: string) => void;
onShare?: (template: StatusTemplate) => void;
}
export const TemplateItem: React.FC<TemplateItemProps> = ({
@ -19,7 +20,14 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
onToggle,
onEdit,
onDelete,
onShare,
}) => {
const isCustom = useMemo(() => isCustomTemplate(template), [template]);
const isModified = useMemo(
() => !isCustom && isTemplateModified(template),
[isCustom, template],
);
const handleClick = (e: React.MouseEvent) => {
// Don't toggle if clicking action buttons
if ((e.target as HTMLElement).closest(".template-item-actions")) {
@ -29,7 +37,7 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
};
return (
<div className={`template-item ${isEnabled ? "enabled" : ""}`}>
<div className={`template-item ${isEnabled ? "enabled" : "disabled"}`}>
<SelectableListItem
selected={isEnabled}
onClick={handleClick}
@ -48,14 +56,47 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
<span className="setting-item-name">
{template.name}
</span>
{isCustomTemplate(template.id) && (
<span className="template-custom-badge">
Custom
</span>
)}
<div className="template-badges">
{isEnabled ? (
<span className="template-badge badge-success">
Active
</span>
) : (
<span className="template-badge badge-muted">
Inactive
</span>
)}
{isCustom ? (
<span className="template-badge badge-accent">
Custom
</span>
) : (
<span className="template-badge badge-info">
Marketplace
</span>
)}
{isModified && (
<span className="template-badge badge-warning">
Modified
</span>
)}
</div>
</div>
<div className="setting-item-description">
{template.description}:
{template.description}
{template.authorGithub && (
<div className="template-author-info">
By{" "}
<a
href={`https://github.com/${template.authorGithub}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
@{template.authorGithub}
</a>
</div>
)}
</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
@ -69,6 +110,18 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
</div>
</SelectableListItem>
<div className="template-item-actions">
{isCustom && onShare && (
<button
onClick={(e) => {
e.stopPropagation();
onShare(template);
}}
title="Submit to Marketplace"
className="template-marketplace-btn"
>
<ObsidianIcon name="share" size={16} />
</button>
)}
{onEdit && (
<button
onClick={(e) => {

View file

@ -2,14 +2,13 @@ import React, { useCallback, useState } from "react";
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import { TemplateItem } from "./TemplateItem";
import { TemplateEditorModal } from "./TemplateEditorModal";
import { MarketplaceShareModal } from "./MarketplaceShareModal";
import { MarketplaceBrowseModal } from "./MarketplaceBrowseModal";
import { ObsidianIcon } from "../atoms/ObsidianIcon";
import {
generateTemplateId,
isTemplateNameUnique,
} from "@/utils/templateUtils";
import {
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "@/constants/predefinedTemplates";
interface TemplateSettingsProps {
settings: PluginSettings;
@ -21,9 +20,14 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
onChange,
}) => {
const [showEditor, setShowEditor] = useState(false);
const [showShare, setShowShare] = useState(false);
const [showMarketplace, setShowMarketplace] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<
StatusTemplate | undefined
>();
const [sharingTemplate, setSharingTemplate] = useState<
StatusTemplate | undefined
>();
const handleTemplateToggle = useCallback(
(templateId: string, enabled: boolean) => {
@ -52,6 +56,43 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
setShowEditor(true);
}, []);
const handleShareTemplate = useCallback((template: StatusTemplate) => {
setSharingTemplate(template);
setShowShare(true);
}, []);
const handleInstallTemplate = useCallback(
(template: StatusTemplate) => {
if (!isTemplateNameUnique(template.name, undefined)) {
alert(
`A template with the name "${template.name}" is already installed.`,
);
return;
}
// Try to use the original ID if it's unique, otherwise generate a new one
const isIdUnique = !settings.templates.some(
(t) => t.id === template.id,
);
const newId = isIdUnique
? template.id
: generateTemplateId(template.name, settings.templates);
const installedTemplate: StatusTemplate = {
...template,
id: newId,
statuses: template.statuses.map((s) => ({
...s,
templateId: newId,
})),
};
onChange("templates", [...settings.templates, installedTemplate]);
handleTemplateToggle(newId, true);
},
[settings.templates, onChange, handleTemplateToggle],
);
const handleSaveTemplate = useCallback(
(template: StatusTemplate) => {
// Validate name uniqueness
@ -72,8 +113,16 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
);
onChange("templates", updatedTemplates);
} else {
// Add new template - generate unique ID
const uniqueId = generateTemplateId(template.name);
// Add new template
// Try to use the original ID if it's unique, otherwise generate a new one
const isIdUnique =
template.id &&
!settings.templates.some((t) => t.id === template.id);
const uniqueId = isIdUnique
? template.id
: generateTemplateId(template.name, settings.templates);
finalTemplate = { ...template, id: uniqueId };
// Update statuses with the final template ID
@ -118,24 +167,24 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
[settings.templates, settings.enabledTemplates, onChange],
);
const handleResetToDefaults = useCallback(() => {
const confirmed = confirm(
"Reset to default templates? This will:\n" +
"• Remove all custom templates\n" +
"• Reset enabled templates to defaults\n" +
"This action cannot be undone.",
);
if (!confirmed) return;
onChange("templates", [...PREDEFINED_TEMPLATES]);
onChange("enabledTemplates", [...DEFAULT_ENABLED_TEMPLATES]);
}, [onChange]);
const handleCancelEditor = useCallback(() => {
setShowEditor(false);
setEditingTemplate(undefined);
}, []);
const handleCancelShare = useCallback(() => {
setShowShare(false);
setSharingTemplate(undefined);
}, []);
const handleOpenMarketplace = useCallback(() => {
setShowMarketplace(true);
}, []);
const handleCloseMarketplace = useCallback(() => {
setShowMarketplace(false);
}, []);
if (showEditor) {
return (
<div>
@ -149,31 +198,55 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
);
}
if (showShare && sharingTemplate) {
return (
<div>
<h3>Status templates</h3>
<MarketplaceShareModal
template={sharingTemplate}
onClose={handleCancelShare}
/>
</div>
);
}
if (showMarketplace) {
return (
<div>
<h3>Status templates</h3>
<MarketplaceBrowseModal
installedTemplates={settings.templates}
onInstall={handleInstallTemplate}
onClose={handleCloseMarketplace}
/>
</div>
);
}
return (
<div>
<h3>Status templates</h3>
<p>
Enable predefined templates to quickly add common status
workflows, or create your own custom templates.
Browse the marketplace to find common status workflows or create
your own custom templates.
</p>
{/* Custom Templates Section */}
<div className="template-section">
<h4 className="template-section-title">Custom Templates</h4>
<div className="template-settings-actions">
<button
className="mod-cta template-create-btn"
className="mod-cta marketplace-browse-btn"
onClick={handleOpenMarketplace}
>
<ObsidianIcon name="globe" size={16} />
Browse Marketplace
</button>
<button
className="template-create-btn"
onClick={handleCreateTemplate}
>
+ Create Template
</button>
<button
className="template-reset-btn"
onClick={handleResetToDefaults}
>
🔄 Reset to Defaults
</button>
</div>
<div className="template-list">
{(settings.templates || []).map((template) => (
@ -186,6 +259,7 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
onToggle={handleTemplateToggle}
onEdit={handleEditTemplate}
onDelete={handleDeleteTemplate}
onShare={handleShareTemplate}
/>
))}
</div>

View file

@ -11,6 +11,7 @@ interface BaseInputProps {
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
}
interface TextInputProps extends BaseInputProps {
@ -38,6 +39,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style,
onFocus,
onBlur,
disabled,
},
ref,
) => {
@ -67,6 +69,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style={style}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
/>
);
},

View file

@ -1,8 +1,4 @@
import { PluginSettings } from "types/pluginSettings";
import {
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "./predefinedTemplates";
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
fileExplorerIconPosition: "absolute-right",
@ -18,7 +14,7 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
showStatusBar: true,
autoHideStatusBar: false,
enableStatusOverviewPopup: true,
templates: [...PREDEFINED_TEMPLATES],
templates: [],
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: true, // Default to hide unknown status
@ -30,7 +26,7 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
enableExperimentalFeatures: true,
enableStatusDashboard: true,
enableGroupedStatusView: true,
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
enabledTemplates: [],
useCustomStatusesOnly: false,
useMultipleStatuses: true,
singleStatusStorageMode: "list",

View file

@ -1,209 +1,278 @@
/**
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
* To add a new template, create a JSON file in the /templates directory.
*/
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: [
"id": "academic",
"name": "Academic research",
"description": "Status workflow for academic research and writing",
"authorGithub": "soler1212",
"statuses": [
{
name: "idea",
icon: "💡",
color: "#FFEB3B",
templateId: "colorful",
"name": "research",
"icon": "🔍",
"color": "#2196F3",
"templateId": "academic"
},
{
name: "draft",
icon: "📝",
color: "#E0E0E0",
templateId: "colorful",
"name": "outline",
"icon": "📑",
"color": "#9E9E9E",
"templateId": "academic"
},
{
name: "inProgress",
icon: "🔧",
color: "#FFC107",
templateId: "colorful",
"name": "draft",
"icon": "✏️",
"color": "#FFC107",
"templateId": "academic"
},
{
name: "editing",
icon: "🖊️",
color: "#2196F3",
templateId: "colorful",
"name": "review",
"icon": "🔬",
"color": "#9C27B0",
"templateId": "academic"
},
{
name: "pending",
icon: "⏳",
color: "#9C27B0",
templateId: "colorful",
"name": "revision",
"icon": "📝",
"color": "#FF5722",
"templateId": "academic"
},
{
name: "onHold",
icon: "⏸",
color: "#9E9E9E",
templateId: "colorful",
"name": "final",
"icon": "📚",
"color": "#4CAF50",
"templateId": "academic"
},
{
name: "needsUpdate",
icon: "🔄",
color: "#FF5722",
templateId: "colorful",
},
{
name: "completed",
icon: "✅",
color: "#4CAF50",
templateId: "colorful",
},
{
name: "archived",
icon: "📦",
color: "#795548",
templateId: "colorful",
},
],
"name": "published",
"icon": "🎓",
"color": "#795548",
"templateId": "academic"
}
]
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
statuses: [
"id": "colorful",
"name": "Colorful workflow",
"description": "A colorful set of workflow statuses with descriptive icons",
"authorGithub": "soler1212",
"statuses": [
{
name: "todo",
icon: "📌",
color: "#F44336",
templateId: "minimal",
"name": "idea",
"icon": "💡",
"color": "#FFEB3B",
"templateId": "colorful"
},
{
name: "inProgress",
icon: "⚙️",
color: "#2196F3",
templateId: "minimal",
"name": "draft",
"icon": "📝",
"color": "#E0E0E0",
"templateId": "colorful"
},
{
name: "review",
icon: "👀",
color: "#9C27B0",
templateId: "minimal",
"name": "inProgress",
"icon": "🔧",
"color": "#FFC107",
"templateId": "colorful"
},
{
name: "done",
icon: "✓",
color: "#4CAF50",
templateId: "minimal",
"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: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
statuses: [
"id": "creative-writing",
"name": "Creative Writing",
"description": "Workflow for novelists and creative writers",
"authorGithub": "soler1212",
"statuses": [
{
name: "research",
icon: "🔍",
color: "#2196F3",
templateId: "academic",
"name": "idea",
"icon": "🧠",
"color": "#FFD700",
"templateId": "creative-writing"
},
{
name: "outline",
icon: "📑",
color: "#9E9E9E",
templateId: "academic",
"name": "outline",
"icon": "🗺️",
"color": "#87CEEB",
"templateId": "creative-writing"
},
{
name: "draft",
icon: "✏️",
color: "#FFC107",
templateId: "academic",
"name": "first-draft",
"icon": "✍",
"color": "#FFA07A",
"templateId": "creative-writing"
},
{
name: "review",
icon: "🔬",
color: "#9C27B0",
templateId: "academic",
"name": "revision",
"icon": "🔍",
"color": "#DA70D6",
"templateId": "creative-writing"
},
{
name: "revision",
icon: "📝",
color: "#FF5722",
templateId: "academic",
},
{
name: "final",
icon: "📚",
color: "#4CAF50",
templateId: "academic",
},
{
name: "published",
icon: "🎓",
color: "#795548",
templateId: "academic",
},
],
"name": "final-polish",
"icon": "✨",
"color": "#32CD32",
"templateId": "creative-writing"
}
]
},
{
id: "project",
name: "Project management",
description: "Status workflow for project management and tracking",
statuses: [
"id": "default-starter",
"name": "Starter Template",
"description": "A simplified set of essential workflow statuses to get you started.",
"authorGithub": "soler1212",
"statuses": [
{
name: "planning",
icon: "🗓️",
color: "#9E9E9E",
templateId: "project",
"name": "todo",
"icon": "📌",
"color": "#F44336",
"templateId": "default-starter"
},
{
name: "backlog",
icon: "📋",
color: "#E0E0E0",
templateId: "project",
"name": "inProgress",
"icon": "⚙️",
"color": "#2196F3",
"templateId": "default-starter"
},
{
name: "ready",
icon: "🚦",
color: "#8BC34A",
templateId: "project",
"name": "review",
"icon": "👀",
"color": "#9C27B0",
"templateId": "default-starter"
},
{
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",
},
],
"name": "done",
"icon": "✓",
"color": "#4CAF50",
"templateId": "default-starter"
}
]
},
{
"id": "minimal",
"name": "Minimal workflow",
"description": "A simplified set of essential workflow statuses",
"authorGithub": "soler1212",
"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": "project",
"name": "Project management",
"description": "Status workflow for project management and tracking",
"authorGithub": "soler1212",
"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

@ -1,5 +1,6 @@
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
import { PluginSettings, SyncGroup } from "@/types/pluginSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
import { Plugin, TFile, normalizePath } from "obsidian";
import eventBus from "./eventBus";
@ -65,7 +66,8 @@ class SettingsService {
async initialize(plugin: Plugin): Promise<void> {
this.plugin = plugin;
await this.loadSettings();
const loadedData = await this.loadSettings();
this.migrateLegacySettings(loadedData);
if (this.settings.enableExternalStatusSync) {
await this.loadFromExternalFile();
@ -104,7 +106,129 @@ class SettingsService {
private async loadSettings() {
const loadedData = await this.plugin.loadData();
this.settings = this.mergeSettings(DEFAULT_PLUGIN_SETTINGS, loadedData);
return this.settings;
this.deduplicateTemplates();
return loadedData;
}
/**
* Migrates legacy settings and ensures new users have a starter template.
*/
private migrateLegacySettings(loadedData: Partial<PluginSettings> | null) {
const hasTemplates =
this.settings.templates && this.settings.templates.length > 0;
// 1. If it's a truly new user (no data at all), give them the starter template
if (!loadedData) {
this.injectStarterTemplate();
return;
}
// 2. If it's an existing user but has no templates, they were using the old defaults
if (!hasTemplates) {
const legacyIds = [
"colorful",
"minimal",
"academic",
"project",
"creative-writing",
];
const enabledLegacy = (this.settings.enabledTemplates || []).filter(
(id) => legacyIds.includes(id),
);
if (enabledLegacy.length > 0) {
// Restore the ones they had enabled from the marketplace list
const toRestore = PREDEFINED_TEMPLATES.filter((t) =>
enabledLegacy.includes(t.id),
).map((t) => ({
...t,
id: t.id, // Keep original ID for legacy compatibility
statuses: t.statuses.map((s) => ({
...s,
templateId: t.id,
})),
}));
this.settings.templates = toRestore;
// Update enabled list
this.settings.enabledTemplates = toRestore.map((t) => t.id);
this.saveSettings().catch(console.error);
} else {
// If nothing was enabled, just give them the starter template
this.injectStarterTemplate();
}
}
}
/**
* Injects the default starter template.
*/
private injectStarterTemplate() {
const starterTemplate = {
id: "starter",
name: "Starter Template",
description:
"A simplified set of essential workflow statuses to get you started.",
authorGithub: "soler1212",
statuses: [
{
name: "todo",
icon: "📌",
color: "#F44336",
templateId: "starter",
},
{
name: "inProgress",
icon: "⚙️",
color: "#2196F3",
templateId: "starter",
},
{
name: "review",
icon: "👀",
color: "#9C27B0",
templateId: "starter",
},
{
name: "done",
icon: "✓",
color: "#4CAF50",
templateId: "starter",
},
],
};
this.settings.templates = [starterTemplate];
if (!this.settings.enabledTemplates.includes(starterTemplate.id)) {
this.settings.enabledTemplates.push(starterTemplate.id);
}
this.saveSettings().catch(console.error);
}
/**
* Removes duplicate templates with the same name, keeping the first occurrence.
*/
private deduplicateTemplates() {
if (!this.settings.templates) return;
const seenNames = new Set<string>();
const uniqueTemplates = [];
let hasDuplicates = false;
for (const template of this.settings.templates) {
const lowerName = template.name.toLowerCase().trim();
if (!seenNames.has(lowerName)) {
seenNames.add(lowerName);
uniqueTemplates.push(template);
} else {
hasDuplicates = true;
}
}
if (hasDuplicates) {
this.settings.templates = uniqueTemplates;
this.saveSettings().catch(console.error);
}
}
/**

View file

@ -1,6 +1,8 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import fs from "fs";
import path from "path";
const banner =
`/*
@ -9,7 +11,45 @@ if you want to view the source, please visit the github repository of this plugi
*/
`;
const prod = (process.argv[2] === "production");
/**
* Automatically generates the predefinedTemplates.ts file by scanning the templates directory.
*/
const generateTemplatesIndex = () => {
const templatesDir = "./templates";
const outputFile = "./constants/predefinedTemplates.ts";
if (!fs.existsSync(templatesDir)) return;
const files = fs.readdirSync(templatesDir);
const templates = [];
files.forEach((file) => {
if (file.endsWith(".json")) {
const filePath = path.join(templatesDir, file);
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
templates.push(content);
}
});
const content = `/**
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
* To add a new template, create a JSON file in the /templates directory.
*/
import { StatusTemplate } from "types/pluginSettings";
export const PREDEFINED_TEMPLATES: StatusTemplate[] = ${JSON.stringify(templates, null, "\t")};
`;
fs.writeFileSync(outputFile, content);
console.log(
`\x1b[36m[Templates]\x1b[0m Automatically registered ${templates.length} templates.`,
);
};
// Generate templates index before build
generateTemplatesIndex();
const prod = process.argv[2] === "production";
// CSS bundling function
const buildStyles = async () => {

View file

@ -84,12 +84,20 @@
box-shadow: 0 2px 8px var(--shadow-color);
}
.template-item.disabled {
opacity: 0.85;
}
.template-item.enabled {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
box-shadow: 0 0 0 1px var(--interactive-accent);
}
.template-item.enabled .setting-item-name {
font-weight: var(--font-bold);
}
.template-item.enabled:hover {
box-shadow:
0 2px 8px var(--shadow-color),
@ -114,20 +122,47 @@
margin-bottom: var(--size-2-1);
}
.template-custom-badge {
.template-badges {
display: flex;
gap: var(--size-2-2);
}
.template-badge {
display: inline-flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-1) var(--size-2-2);
background: var(--interactive-accent);
color: var(--text-on-accent);
font-size: var(--font-ui-smaller);
font-weight: var(--font-medium);
padding: 2px 8px;
border-radius: var(--radius-s);
font-size: 10px;
font-weight: var(--font-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-success {
background-color: var(--background-modifier-success);
color: var(--text-on-accent);
}
.badge-muted {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.badge-accent {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.badge-warning {
background-color: var(--background-modifier-warning);
color: var(--text-on-accent);
}
.badge-info {
background-color: var(--background-modifier-info);
color: var(--text-on-accent);
}
/* Template Item Actions */
.template-item-actions {
display: flex;
@ -141,6 +176,31 @@
justify-content: center;
}
.template-item-actions button,
.template-item-actions a {
display: flex;
align-items: center;
justify-content: center;
padding: var(--size-4-1);
border-radius: var(--radius-s);
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all var(--anim-duration-fast) ease;
text-decoration: none !important;
}
.template-item-actions button:hover,
.template-item-actions a:hover {
background: var(--interactive-hover);
color: var(--text-normal);
}
.template-marketplace-btn {
color: var(--text-accent) !important;
}
/* Template Editor Modal */
.template-editor-modal {
background: var(--background-primary);
@ -229,6 +289,214 @@
transform: none;
}
.template-author-info {
display: block;
font-size: var(--font-ui-smaller);
color: var(--text-muted);
margin-top: var(--size-2-1);
font-style: italic;
}
.template-author-info a {
color: var(--text-accent);
text-decoration: none;
}
.template-author-info a:hover {
text-decoration: underline;
}
/* Marketplace Share Modal */
.marketplace-steps {
padding-left: var(--size-4-4);
margin-bottom: var(--size-4-4);
color: var(--text-normal);
}
.marketplace-steps li {
margin-bottom: var(--size-2-2);
}
.marketplace-steps li a {
color: var(--text-accent);
word-break: break-all;
}
.marketplace-note {
margin: var(--size-4-4) 0;
padding: var(--size-4-2);
background: var(--background-primary-alt);
border-left: 4px solid var(--interactive-accent);
border-radius: var(--radius-s);
font-size: var(--font-ui-small);
}
.marketplace-json-preview {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
overflow: hidden;
margin-top: var(--size-4-2);
}
.json-preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--size-2-2) var(--size-4-2);
background: var(--background-primary-alt);
border-bottom: 1px solid var(--background-modifier-border);
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
.copy-btn {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-1) var(--size-2-3) !important;
font-size: var(--font-ui-smaller) !important;
background: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
border: none !important;
}
.copy-btn.success {
background: var(--text-success) !important;
}
.marketplace-json-preview pre {
margin: 0;
padding: var(--size-4-2);
max-height: 300px;
overflow-y: auto;
font-family: var(--font-monospace);
font-size: var(--font-ui-smaller);
white-space: pre-wrap;
word-break: break-all;
}
.marketplace-github-link {
display: flex;
align-items: center;
gap: var(--size-2-2);
text-decoration: none !important;
}
/* Marketplace Browse Modal */
.marketplace-header-content {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: var(--size-4-4);
}
.marketplace-header-content h2 {
margin: 0;
white-space: nowrap;
}
.marketplace-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--size-4-4);
padding: var(--size-2-2);
}
.marketplace-card {
display: flex;
flex-direction: column;
background: var(--background-primary-alt);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-4);
transition: all var(--anim-duration-fast) ease;
}
.marketplace-card:hover {
border-color: var(--interactive-accent);
box-shadow: var(--shadow-s);
}
.marketplace-card-header {
margin-bottom: var(--size-4-2);
}
.marketplace-card-title {
font-weight: var(--font-bold);
font-size: var(--font-ui-large);
color: var(--text-normal);
margin-bottom: var(--size-2-2);
}
.marketplace-card-meta {
display: flex;
align-items: center;
gap: var(--size-2-2);
}
.marketplace-card-author {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
font-style: italic;
}
.marketplace-card-description {
font-size: var(--font-ui-small);
color: var(--text-muted);
margin-bottom: var(--size-4-2);
flex-grow: 1;
}
.marketplace-card-statuses {
display: flex;
flex-wrap: wrap;
gap: var(--size-2-1);
margin-bottom: var(--size-4-4);
}
.marketplace-card-actions {
margin-top: auto;
}
.marketplace-install-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-2-2);
}
.marketplace-install-btn.installed {
background: var(--background-modifier-success-accent) !important;
color: var(--text-on-accent) !important;
opacity: 0.8;
cursor: default;
}
.marketplace-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--size-4-12);
color: var(--text-muted);
text-align: center;
}
.marketplace-empty p {
margin-top: var(--size-4-2);
font-size: var(--font-ui-large);
}
.marketplace-browse-btn {
display: flex;
align-items: center;
gap: var(--size-2-2);
}
/* Responsive Design */
@media (max-width: 768px) {
.template-settings-actions {

50
templates/academic.json Normal file
View file

@ -0,0 +1,50 @@
{
"id": "academic",
"name": "Academic research",
"description": "Status workflow for academic research and writing",
"authorGithub": "soler1212",
"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"
}
]
}

62
templates/colorful.json Normal file
View file

@ -0,0 +1,62 @@
{
"id": "colorful",
"name": "Colorful workflow",
"description": "A colorful set of workflow statuses with descriptive icons",
"authorGithub": "soler1212",
"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"
}
]
}

View file

@ -0,0 +1,38 @@
{
"id": "creative-writing",
"name": "Creative Writing",
"description": "Workflow for novelists and creative writers",
"authorGithub": "soler1212",
"statuses": [
{
"name": "idea",
"icon": "🧠",
"color": "#FFD700",
"templateId": "creative-writing"
},
{
"name": "outline",
"icon": "🗺️",
"color": "#87CEEB",
"templateId": "creative-writing"
},
{
"name": "first-draft",
"icon": "✍️",
"color": "#FFA07A",
"templateId": "creative-writing"
},
{
"name": "revision",
"icon": "🔍",
"color": "#DA70D6",
"templateId": "creative-writing"
},
{
"name": "final-polish",
"icon": "✨",
"color": "#32CD32",
"templateId": "creative-writing"
}
]
}

View file

@ -0,0 +1,32 @@
{
"id": "default-starter",
"name": "Starter Template",
"description": "A simplified set of essential workflow statuses to get you started.",
"authorGithub": "soler1212",
"statuses": [
{
"name": "todo",
"icon": "📌",
"color": "#F44336",
"templateId": "default-starter"
},
{
"name": "inProgress",
"icon": "⚙️",
"color": "#2196F3",
"templateId": "default-starter"
},
{
"name": "review",
"icon": "👀",
"color": "#9C27B0",
"templateId": "default-starter"
},
{
"name": "done",
"icon": "✓",
"color": "#4CAF50",
"templateId": "default-starter"
}
]
}

32
templates/minimal.json Normal file
View file

@ -0,0 +1,32 @@
{
"id": "minimal",
"name": "Minimal workflow",
"description": "A simplified set of essential workflow statuses",
"authorGithub": "soler1212",
"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"
}
]
}

56
templates/project.json Normal file
View file

@ -0,0 +1,56 @@
{
"id": "project",
"name": "Project management",
"description": "Status workflow for project management and tracking",
"authorGithub": "soler1212",
"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"
}
]
}

View file

@ -26,7 +26,8 @@
"lib": ["DOM", "ES5", "ES6", "ES7"],
"jsx": "react-jsx",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true
},
"include": ["**/*.ts", "**/*.tsx"]
}

View file

@ -19,6 +19,7 @@ export interface StatusTemplate {
id: string;
name: string;
description: string;
authorGithub?: string;
statuses: NoteStatus[];
}

View file

@ -3,11 +3,61 @@ import settingsService from "@/core/settingsService";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
/**
* Get all enabled templates
* Check if a template is a custom user template (not from marketplace)
*/
export const isCustomTemplate = (templateId: string): boolean => {
const i = PREDEFINED_TEMPLATES.findIndex((t) => t.id === templateId);
return i === -1;
export const isCustomTemplate = (template: StatusTemplate): boolean => {
// Either matches an original marketplace template ID
const matchesPredefined = PREDEFINED_TEMPLATES.some(
(pt) =>
pt.id === template.id ||
pt.name.toLowerCase() === template.name.toLowerCase(),
);
if (matchesPredefined) return false;
// Or has metadata from marketplace
const hasMetadata = !!template.authorGithub;
if (hasMetadata) return false;
return true;
};
/**
* Check if a marketplace template has been modified by the user
*/
export const isTemplateModified = (template: StatusTemplate): boolean => {
const original = PREDEFINED_TEMPLATES.find(
(pt) =>
pt.id === template.id ||
pt.name.toLowerCase() === template.name.toLowerCase(),
);
if (!original) return false;
// Compare relevant fields to see if anything changed
// We stringify for a deep comparison of statuses
const originalData = JSON.stringify({
name: original.name,
description: original.description,
statuses: original.statuses.map((s) => ({
name: s.name,
icon: s.icon,
lucideIcon: s.lucideIcon,
color: s.color,
})),
});
const currentData = JSON.stringify({
name: template.name,
description: template.description,
statuses: template.statuses.map((s) => ({
name: s.name,
icon: s.icon,
lucideIcon: s.lucideIcon,
color: s.color,
})),
});
return originalData !== currentData;
};
/**
@ -36,9 +86,13 @@ export const isTemplateNameUnique = (
/**
* Generate unique template ID
*/
export const generateTemplateId = (name: string): string => {
export const generateTemplateId = (
name: string,
existingTemplates?: StatusTemplate[],
): string => {
const baseId = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
const allTemplates = settingsService.settings.templates;
const allTemplates =
existingTemplates || settingsService.settings.templates;
let counter = 1;
let id = `custom-${baseId}`;
@ -50,3 +104,4 @@ export const generateTemplateId = (name: string): string => {
return id;
};