Compare commits
No commits in common. "master" and "2.2.0" have entirely different histories.
227
README.md
|
|
@ -1,148 +1,149 @@
|
|||
# Note Status for Obsidian
|
||||
|
||||
[](https://paypal.me/aleixsoler)
|
||||
[](https://buymeacoffee.com/aleixsoler)
|
||||
|
||||
[](https://github.com/devonthesofa/obsidian-note-status/releases)
|
||||
[](https://github.com/devonthesofa/obsidian-note-status/actions)
|
||||
[](https://obsidian.md/plugins?id=note-status)
|
||||
[](https://github.com/devonthesofa/obsidian-note-status/blob/master/LICENSE)
|
||||
[](https://github.com/devonthesofa/obsidian-note-status/stargazers)
|
||||
|
||||
Track the lifecycle of every file in your vault using statuses you fully control. Note Status brings visual clarity and structured workflows to Obsidian—whether you're managing research notes, writing projects, tasks, or long-term archives.
|
||||
Track the status of your notes with a powerful, customizable status management system. Mark notes as active, completed, on hold, or create your own custom workflows.
|
||||
|
||||
It works on both Markdown and non-Markdown files, integrates into multiple UI surfaces, and provides configurable dashboards, commands, and fast actions to keep information flowing.
|
||||

|
||||
|
||||
## Contents
|
||||
_Alt: Demonstration of assigning status to a note using the toolbar dropdown_
|
||||
|
||||
- [Feature Guide](docs/features.md): what the plugin can do across commands, UI surfaces, and workflows.
|
||||
- [Settings Reference](docs/settings.md): every option, grouped like the in-app settings.
|
||||
## 🎯 Features
|
||||
|
||||

|
||||

|
||||
- **📋 Status Assignment** - Mark notes with workflow statuses (active, completed, on hold, etc.)
|
||||
- **🎯 Multi-Status Support** - Assign multiple statuses per note
|
||||
- **📁 File Explorer Icons** - Visual indicators next to filenames with configurable positioning
|
||||
- **🧭 Status Bar Integration** - View current status at bottom of editor
|
||||
- **⬇️ Toolbar Dropdown** - Change statuses quickly from editor toolbar
|
||||
- **📊 Status Dashboard** - Vault overview with statistics and quick actions
|
||||
- **📈 Grouped View** - Browse notes organized by status with search and filtering
|
||||
- **⚡ Batch Operations** - Update multiple files simultaneously
|
||||
- **🎨 Custom Statuses** - Create your own statuses with icons, colors, and descriptions
|
||||
- **🏷️ Template Scoping** - Organize statuses by note templates
|
||||
- **🚀 Performance Optimized** - Works with large vaults (40k+ notes tested)
|
||||
|
||||
## Quick start
|
||||

|
||||
|
||||
1. Open Settings → Note Status.
|
||||
2. Expand **Templates & Statuses** and keep a built-in template or add your own/custom-only set.
|
||||
3. Pick where to see/change status: **Status Bar** or **Editor Toolbar**.
|
||||
4. Set a status from the badge or run `Change current note status`.
|
||||
5. Watch file explorer icons update; hover for the overview popup or click to change again.
|
||||
6. If enabled, open the **Status Dashboard** or **Grouped Status View** from the ribbon/commands.
|
||||
_Alt: File explorer showing multiple notes with different status icons positioned next to note names and the tooltip with status overview_
|
||||
|
||||
## Key features
|
||||

|
||||
|
||||
- Status picker everywhere: status bar badge, editor toolbar, explorer context menus, commands.
|
||||
- Multi-status or single-status workflows; quick-status commands for hotkeys.
|
||||
- File explorer icons with configurable placement, frames, and visual treatments (row tint, underline, dot, border).
|
||||
- Status bar badges with accent/filled/dot styles and icon/text options; template names can auto-show on conflicts.
|
||||
- Dashboards and grouped views (gated by experimental toggles and vault size limit) for vault-wide visibility and quick actions.
|
||||
- Built-in templates (Colorful, Minimal, Academic, Project) plus full custom templates and standalone custom statuses.
|
||||
- Works with YAML frontmatter and non-Markdown via a synced JSON store; frontmatter mappings let you write to multiple keys.
|
||||
_Alt: Status dashboard showing current note information, vault statistics with total notes and coverage percentages_
|
||||

|
||||
|
||||
## Data model
|
||||
_Alt: Status distribution chart showing different statuses with colored bars and note counts for each status type_
|
||||

|
||||
|
||||
- **Markdown**: statuses live under the tag prefix (default `obsidian-note-status`). Map templates/statuses to extra YAML keys and optionally also write to the default.
|
||||
- **Non-Markdown**: stored in `.obsidian/plugins/obsidian-note-status/non-markdown-statuses.json`, kept in sync on rename/delete.
|
||||
- **Single vs multi**: multiple statuses by default; single-status mode can store as list (`status: [draft]`) or string (`status: draft`) for compatibility.
|
||||
_Alt: Quick actions panel with organized sections for views, current note operations, clipboard actions, and tools_
|
||||
|
||||
## Screenshots
|
||||
## 💻 Technology Stack
|
||||
|
||||
- Nav bar with current status
|
||||
Built with modern web technologies for performance and reliability:
|
||||
|
||||
- 
|
||||
- 
|
||||
- **TypeScript** - Type-safe development
|
||||
- **React** - Modern UI framework
|
||||
- **esbuild** - Fast bundling and development
|
||||
- **Obsidian API** - Deep vault integration
|
||||
|
||||
- Status assignment:
|
||||
## 📦 Installation
|
||||
|
||||
- 
|
||||
### Community Plugin Store (Recommended)
|
||||
|
||||
- File explorer with status icons and hover overview:
|
||||
- 
|
||||
- Multiple settings:
|
||||
- 
|
||||
- Custom templates editor:
|
||||
- 
|
||||
- 
|
||||
- Frontmatter tags mapping
|
||||
- 
|
||||
- Grouped status view:
|
||||
- 
|
||||
- Status dashboard:
|
||||
- 
|
||||
- 
|
||||
- 
|
||||
1. Open Obsidian → Settings → Community plugins
|
||||
2. Disable Safe mode → Browse → Search "Note Status"
|
||||
3. Install and Enable
|
||||
|
||||
## Template Marketplace
|
||||
### Manual Installation
|
||||
|
||||
You can contribute your own status templates to the plugin!
|
||||
1. Download latest release from [GitHub Releases](https://github.com/devonthesofa/obsidian-note-status/releases)
|
||||
2. Extract `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/note-status/`
|
||||
3. Enable in Community plugins settings
|
||||
|
||||
### How to contribute (The Easy Way)
|
||||
## 🚀 How to Use
|
||||
|
||||
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!
|
||||
### Quick Start
|
||||
|
||||
### How to contribute (Manual)
|
||||
1. **Assign Status**:
|
||||
|
||||
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!
|
||||
1. Click the "No status" in toolbar
|
||||
|
||||
Once accepted and merged, your template will be automatically included in the next build and available in the marketplace for all users.
|
||||

|
||||
|
||||
## Installation
|
||||
_Alt: Editor toolbar showing "No status" button for status assignment_
|
||||
|
||||
### Community Plugin Store (recommended)
|
||||
2. → Select a status
|
||||
|
||||
1. Obsidian → Settings → Community plugins.
|
||||
2. Disable Safe mode → Browse → search “Note Status”.
|
||||
3. Install and enable.
|
||||
2. **View in Explorer**: Status icons appear next to note names
|
||||
|
||||
### Manual
|
||||

|
||||
|
||||
1. Download the latest release from [GitHub Releases](https://github.com/devonthesofa/obsidian-note-status/releases).
|
||||
2. Extract `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/note-status/`.
|
||||
3. Enable in Community plugins settings.
|
||||
_Alt: Single file explorer item showing a note with status icon positioned next to the filename_
|
||||
|
||||
## Example workflows
|
||||
3. **Browse by Status or open the Dashboard**: Open from left sidebar
|
||||
|
||||
**Personal knowledge**
|
||||

|
||||
|
||||
_Alt: Left sidebar showing ribbon buttons for opening grouped status view and status dashboard_
|
||||
|
||||
4. **Batch Update**: Select multiple files → Right-click → Change status
|
||||
|
||||

|
||||
|
||||
_Alt: Context menu appearing when right-clicking multiple selected files, showing "Change note status" option_
|
||||
|
||||
### Example Workflows
|
||||
|
||||
**Personal Knowledge Management**
|
||||
|
||||
```
|
||||
💡 idea → 📝 draft → ✏️ editing → ✅ completed → 📦 archived
|
||||
```
|
||||
|
||||
**Project delivery**
|
||||
**Project Management**
|
||||
|
||||
```
|
||||
📋 backlog → 🚦 ready → 👨💻 inDevelopment → 🧪 testing → 🚀 live
|
||||
```
|
||||
|
||||
**Academic**
|
||||
**Academic Research**
|
||||
|
||||
```
|
||||
🔍 research → 📑 outline → ✏️ draft → 🔬 review → 📚 final
|
||||
```
|
||||
|
||||
## Data format
|
||||

|
||||
|
||||
Statuses in YAML(as string or array, it depends of your settings):
|
||||
_Alt: Grouped status view showing notes organized by status with template filtering and search functionality_
|
||||
|
||||
### Custom Statuses
|
||||
|
||||
Create your own workflow:
|
||||
|
||||
1. **Settings** → Note Status → Custom Statuses
|
||||
2. **Add Status** → Configure name, icon, color, description
|
||||
3. **Reorder** using arrow controls
|
||||
4. **Use** in your notes
|
||||
|
||||
Example custom status:
|
||||
|
||||
```
|
||||
Name: urgent
|
||||
Icon: 🔥
|
||||
Color: #FF0000
|
||||
Description: Requires immediate attention
|
||||
```
|
||||
|
||||

|
||||
|
||||
_Alt: Status selection modal showing custom and template statuses with icons, names, and selection interface_
|
||||
|
||||
## 📋 Data Format
|
||||
|
||||
Statuses are stored in YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
|
|
@ -152,13 +153,49 @@ obsidian-note-status:
|
|||
---
|
||||
```
|
||||
|
||||
Works with Dataview, Templater, QuickAdd, and search. Non-Markdown uses the JSON store noted above.
|
||||
Works with:
|
||||
|
||||
## Support the project
|
||||
- **Dataview queries**
|
||||
- **Templater scripts**
|
||||
- **QuickAdd macros**
|
||||
- **Global search**
|
||||
|
||||
## 🛠️ API Reference
|
||||
|
||||
For developers building integrations or contributing:
|
||||
|
||||
📚 **[Developer Documentation](https://github.com/devonthesofa/obsidian-note-status/wiki)**
|
||||
|
||||
Includes:
|
||||
|
||||
- Architecture overview
|
||||
- Development setup
|
||||
- API reference
|
||||
- Contributing guide
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions!
|
||||
|
||||
1. **Check** [GitHub Issues](https://github.com/devonthesofa/obsidian-note-status/issues) for bugs/features
|
||||
2. **Fork** the repository
|
||||
3. **Follow** [Development Setup](https://github.com/devonthesofa/obsidian-note-status/wiki/🔧-Development-Setup)
|
||||
4. **Submit** pull request
|
||||
|
||||
## 🐛 Support
|
||||
|
||||
- **📖 Documentation**: [Complete Wiki](https://github.com/devonthesofa/obsidian-note-status/wiki)
|
||||
- **🐞 Bug Reports**: [GitHub Issues](https://github.com/devonthesofa/obsidian-note-status/issues)
|
||||
- **💡 Feature Requests**: [GitHub Discussions](https://github.com/devonthesofa/obsidian-note-status/discussions)
|
||||
|
||||
## ❤️ Support Development
|
||||
|
||||
If you find this plugin useful:
|
||||
|
||||
[](https://paypal.me/aleixsoler)
|
||||
[](https://buymeacoffee.com/aleixsoler)
|
||||
|
||||
**PayPal**: https://paypal.me/aleixsoler
|
||||
|
||||
---
|
||||
|
||||
**License**: MIT · **Author**: [Aleix Soler](https://aleixsoler.com)
|
||||
**License**: MIT | **Author**: [Aleix Soler](https://aleixsoler.com)
|
||||
|
|
|
|||
|
|
@ -4,16 +4,11 @@ import {
|
|||
StatusSelectorGroup,
|
||||
Props as StatusSelectorGroupProps,
|
||||
} from "./StatusSelectorGroup";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
|
||||
export interface Props {
|
||||
currentStatuses: GroupedStatuses;
|
||||
filesQuantity: number;
|
||||
availableStatuses: NoteStatus[];
|
||||
templates: StatusTemplate[];
|
||||
iconFrameMode?: "always" | "never";
|
||||
iconColorMode?: "status" | "theme";
|
||||
activeStatus?: NoteStatus;
|
||||
onRemoveStatus: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
|
|
@ -28,10 +23,6 @@ export const ChangeStatusModal: React.FC<Props> = ({
|
|||
currentStatuses: initialStatuses,
|
||||
filesQuantity,
|
||||
availableStatuses,
|
||||
templates,
|
||||
iconFrameMode = "never",
|
||||
iconColorMode = "status",
|
||||
activeStatus,
|
||||
onRemoveStatus,
|
||||
onSelectStatus,
|
||||
}) => {
|
||||
|
|
@ -62,10 +53,6 @@ export const ChangeStatusModal: React.FC<Props> = ({
|
|||
frontmatterTagName={frontmatterTagName}
|
||||
availableStatuses={availableStatuses}
|
||||
currentStatuses={statusList}
|
||||
templates={templates}
|
||||
iconFrameMode={iconFrameMode}
|
||||
iconColorMode={iconColorMode}
|
||||
activeStatus={activeStatus}
|
||||
onSelectedState={handleSelectedState}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,16 @@ export const CurrentStatusChips: React.FC<Props> = ({
|
|||
}) => {
|
||||
return (
|
||||
<SettingItem name="Current statuses" vertical>
|
||||
<div className="note-status-chips">
|
||||
<div
|
||||
className="note-status-chips"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "6px",
|
||||
minHeight: "32px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{currentStatuses.map((status) => (
|
||||
<StatusDisplay
|
||||
key={`${status.templateId || "custom"}:${status.name}`}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { SearchFilter } from "../atoms/SearchFilter";
|
||||
import { StatusSelector } from "../atoms/StatusSelector";
|
||||
import { SettingItem } from "../SettingsUI/SettingItem";
|
||||
import { CurrentStatusChips } from "./CurrentStatusChips";
|
||||
import { useKeyboardNavigation } from "./useKeyboardNavigation";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
|
||||
export interface Props {
|
||||
frontmatterTagName: string;
|
||||
currentStatuses: NoteStatus[];
|
||||
availableStatuses: NoteStatus[];
|
||||
templates: StatusTemplate[];
|
||||
iconFrameMode: "always" | "never";
|
||||
iconColorMode: "status" | "theme";
|
||||
activeStatus?: NoteStatus;
|
||||
onSelectedState: (
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
|
|
@ -25,16 +20,9 @@ export interface Props {
|
|||
export const StatusSelectorGroup: React.FC<Props> = ({
|
||||
currentStatuses,
|
||||
availableStatuses,
|
||||
templates,
|
||||
frontmatterTagName,
|
||||
iconFrameMode,
|
||||
iconColorMode,
|
||||
activeStatus,
|
||||
onSelectedState,
|
||||
}) => {
|
||||
const TEMPLATE_ALL_VIEW = "__all__";
|
||||
const TEMPLATE_CUSTOM_VIEW = "__custom__";
|
||||
|
||||
const handleRemoveStatus = useCallback(
|
||||
(status: NoteStatus) => {
|
||||
onSelectedState(frontmatterTagName, status, "unselected");
|
||||
|
|
@ -49,105 +37,6 @@ export const StatusSelectorGroup: React.FC<Props> = ({
|
|||
[onSelectedState, frontmatterTagName],
|
||||
);
|
||||
|
||||
type TemplateViewOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
statuses: NoteStatus[];
|
||||
};
|
||||
|
||||
const templateViewOptions = useMemo<TemplateViewOption[]>(() => {
|
||||
const statusesByTemplate = new Map<string, NoteStatus[]>();
|
||||
const customStatuses: NoteStatus[] = [];
|
||||
|
||||
availableStatuses.forEach((status) => {
|
||||
if (status.templateId) {
|
||||
const bucket = statusesByTemplate.get(status.templateId) ?? [];
|
||||
bucket.push(status);
|
||||
statusesByTemplate.set(status.templateId, bucket);
|
||||
} else {
|
||||
customStatuses.push(status);
|
||||
}
|
||||
});
|
||||
|
||||
const templateOrder = templates.map((template) => template.id);
|
||||
const templateNameMap = new Map(
|
||||
templates.map((template) => [template.id, template.name]),
|
||||
);
|
||||
|
||||
const views: TemplateViewOption[] = [
|
||||
{
|
||||
id: TEMPLATE_ALL_VIEW,
|
||||
label: "All",
|
||||
statuses: availableStatuses,
|
||||
},
|
||||
];
|
||||
|
||||
templateOrder.forEach((templateId) => {
|
||||
const statuses = statusesByTemplate.get(templateId);
|
||||
if (statuses?.length) {
|
||||
views.push({
|
||||
id: templateId,
|
||||
label: templateNameMap.get(templateId) ?? templateId,
|
||||
statuses,
|
||||
});
|
||||
statusesByTemplate.delete(templateId);
|
||||
}
|
||||
});
|
||||
|
||||
// Include any template-defined statuses that aren't present in the settings order
|
||||
statusesByTemplate.forEach((statuses, templateId) => {
|
||||
if (statuses.length) {
|
||||
views.push({
|
||||
id: templateId,
|
||||
label: templateNameMap.get(templateId) ?? templateId,
|
||||
statuses,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (customStatuses.length) {
|
||||
views.push({
|
||||
id: TEMPLATE_CUSTOM_VIEW,
|
||||
label: "Custom",
|
||||
statuses: customStatuses,
|
||||
});
|
||||
}
|
||||
|
||||
return views;
|
||||
}, [availableStatuses, templates]);
|
||||
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
|
||||
if (activeStatus && activeStatus.templateId) {
|
||||
return activeStatus.templateId;
|
||||
}
|
||||
if (currentStatuses && currentStatuses.length > 0) {
|
||||
const statusWithTemplate = currentStatuses.find(
|
||||
(s) => s.templateId,
|
||||
);
|
||||
if (statusWithTemplate) {
|
||||
return statusWithTemplate.templateId as string;
|
||||
}
|
||||
return TEMPLATE_CUSTOM_VIEW;
|
||||
}
|
||||
return TEMPLATE_ALL_VIEW;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const exists = templateViewOptions.some(
|
||||
(option) => option.id === selectedTemplateId,
|
||||
);
|
||||
if (!exists) {
|
||||
setSelectedTemplateId(TEMPLATE_ALL_VIEW);
|
||||
}
|
||||
}, [templateViewOptions, selectedTemplateId]);
|
||||
|
||||
const activeTemplate =
|
||||
templateViewOptions.find(
|
||||
(option) => option.id === selectedTemplateId,
|
||||
) ?? templateViewOptions[0];
|
||||
|
||||
const statusesForView = activeTemplate?.statuses ?? [];
|
||||
|
||||
const {
|
||||
focusedIndex,
|
||||
searchFilter,
|
||||
|
|
@ -157,7 +46,7 @@ export const StatusSelectorGroup: React.FC<Props> = ({
|
|||
handleKeyDown,
|
||||
setSearchFilter,
|
||||
} = useKeyboardNavigation({
|
||||
availableStatuses: statusesForView,
|
||||
availableStatuses,
|
||||
currentStatuses,
|
||||
onSelectStatus: handleSelectStatus,
|
||||
onRemoveStatus: handleRemoveStatus,
|
||||
|
|
@ -168,33 +57,8 @@ export const StatusSelectorGroup: React.FC<Props> = ({
|
|||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="status-selector-group"
|
||||
style={{ outline: "none" }}
|
||||
>
|
||||
{templateViewOptions.length > 1 && (
|
||||
<div
|
||||
className="note-status-template-tabs"
|
||||
role="tablist"
|
||||
aria-label="Status templates"
|
||||
>
|
||||
{templateViewOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
key={option.id}
|
||||
className={`note-status-template-tab${
|
||||
option.id === selectedTemplateId
|
||||
? " is-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setSelectedTemplateId(option.id)}
|
||||
aria-selected={option.id === selectedTemplateId}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchFilter
|
||||
ref={searchRef}
|
||||
value={searchFilter}
|
||||
|
|
@ -203,7 +67,14 @@ export const StatusSelectorGroup: React.FC<Props> = ({
|
|||
|
||||
<SettingItem name="Current statuses" vertical>
|
||||
{filteredStatuses.length === 0 ? (
|
||||
<div className="status-selector-empty">
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
textAlign: "center",
|
||||
color: "var(--text-muted)",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{searchFilter
|
||||
? `No statuses match "${searchFilter}"`
|
||||
: "No statuses found"}
|
||||
|
|
@ -213,8 +84,6 @@ export const StatusSelectorGroup: React.FC<Props> = ({
|
|||
availableStatuses={filteredStatuses}
|
||||
currentStatuses={currentStatuses}
|
||||
focusedIndex={focusedIndex}
|
||||
iconFrameMode={iconFrameMode}
|
||||
iconColorMode={iconColorMode}
|
||||
onToggleStatus={(status, selected) =>
|
||||
selected
|
||||
? handleSelectStatus(status)
|
||||
|
|
|
|||
|
|
@ -1,41 +1,24 @@
|
|||
import { StatusIconPreview } from "@/components/atoms/StatusIconPreview";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import React, { FC, memo } from "react";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
import {
|
||||
getPrimaryStatus,
|
||||
getUnknownStatusColor,
|
||||
resolveStatusColor,
|
||||
} from "@/utils/statusColor";
|
||||
|
||||
type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
onMouseEnter: (payload: {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
}) => void;
|
||||
onMouseLeave: () => void;
|
||||
onMouseEnter: (statuses: GroupedStatuses) => void;
|
||||
onMouseLeave: (statuses: GroupedStatuses) => void;
|
||||
hideUnknownStatus?: boolean;
|
||||
unknownStatusConfig?: {
|
||||
icon: string;
|
||||
lucideIcon?: string;
|
||||
color: string;
|
||||
};
|
||||
iconFrameMode?: "always" | "never";
|
||||
iconColorMode?: "status" | "theme";
|
||||
};
|
||||
|
||||
export const FileExplorerIcon: FC<Props> = memo(
|
||||
({
|
||||
statuses,
|
||||
defaultTagName,
|
||||
onMouseLeave,
|
||||
onMouseEnter,
|
||||
hideUnknownStatus,
|
||||
unknownStatusConfig,
|
||||
iconFrameMode = "never",
|
||||
iconColorMode = "status",
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const totalStatuses = statusEntries.reduce(
|
||||
|
|
@ -43,58 +26,54 @@ export const FileExplorerIcon: FC<Props> = memo(
|
|||
0,
|
||||
);
|
||||
|
||||
const useStatusColors = iconColorMode === "status";
|
||||
|
||||
if (totalStatuses === 0) {
|
||||
// If hideUnknownStatus is enabled, don't show anything for files without status
|
||||
if (hideUnknownStatus) return null;
|
||||
|
||||
// Use config passed from integration, with fallbacks
|
||||
const icon = unknownStatusConfig?.icon || "❓";
|
||||
const color = useStatusColors
|
||||
? unknownStatusConfig?.color?.trim() || getUnknownStatusColor()
|
||||
: undefined;
|
||||
const color = unknownStatusConfig?.color || "#8b949e";
|
||||
|
||||
return (
|
||||
<StatusIconPreview
|
||||
icon={
|
||||
<StatusIcon
|
||||
icon={icon}
|
||||
lucideIcon={unknownStatusConfig?.lucideIcon}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
color={color}
|
||||
iconColorMode={iconColorMode}
|
||||
iconFrameMode={iconFrameMode}
|
||||
onMouseEnter={() =>
|
||||
onMouseEnter({ statuses: {}, defaultTagName })
|
||||
}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<div className="status-wrapper">
|
||||
<div
|
||||
className="status-minimal status-minimal--no-status"
|
||||
onMouseEnter={() => onMouseEnter({})}
|
||||
onMouseLeave={() => onMouseLeave({})}
|
||||
style={
|
||||
{
|
||||
"--primary-color": color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="status-icon">{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primaryStatus = getPrimaryStatus(statuses);
|
||||
const primaryStatus = statusEntries[0]?.[1]?.[0];
|
||||
if (!primaryStatus) return null;
|
||||
const iconColor = useStatusColors
|
||||
? resolveStatusColor(primaryStatus)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StatusIconPreview
|
||||
icon={
|
||||
<StatusIcon
|
||||
icon={primaryStatus.icon}
|
||||
lucideIcon={primaryStatus.lucideIcon}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
color={iconColor}
|
||||
count={totalStatuses}
|
||||
iconFrameMode={iconFrameMode}
|
||||
iconColorMode={iconColorMode}
|
||||
onMouseEnter={() => onMouseEnter({ statuses, defaultTagName })}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
<div className="status-wrapper">
|
||||
<div
|
||||
className="status-minimal"
|
||||
onMouseEnter={() => onMouseEnter(statuses)}
|
||||
onMouseLeave={() => onMouseLeave(statuses)}
|
||||
style={
|
||||
{
|
||||
"--primary-color":
|
||||
primaryStatus.color || "var(--text-accent)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="status-icon">{primaryStatus.icon}</span>
|
||||
{totalStatuses > 1 && (
|
||||
<span className="status-count">{totalStatuses}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ export type StatusItem = {
|
|||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
lucideIcon?: string;
|
||||
};
|
||||
|
||||
export type FilesByStatus = {
|
||||
|
|
@ -70,7 +69,6 @@ const GroupedStatusViewContent = () => {
|
|||
name: noteStatus.name,
|
||||
color: noteStatus.color || "white",
|
||||
icon: noteStatus.icon,
|
||||
lucideIcon: noteStatus.lucideIcon,
|
||||
};
|
||||
|
||||
const scopedIdentifier = noteStatus.templateId
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useCallback } from "react";
|
||||
import { FileItem } from "../GroupedStatusView";
|
||||
import { SelectableListItem } from "../../atoms/SelectableListItem";
|
||||
|
||||
interface FileListProps {
|
||||
files: FileItem[];
|
||||
groupKey: string;
|
||||
|
|
@ -46,23 +48,21 @@ export const FileList = ({
|
|||
<div className="grouped-status-files">
|
||||
<div className="grouped-status-files-list" onScroll={handleScroll}>
|
||||
{visibleFiles.map((file) => (
|
||||
<div
|
||||
<SelectableListItem
|
||||
key={file.id}
|
||||
className="tree-item nav-file grouped-status-file-item"
|
||||
onClick={() => handleFileClick(file)}
|
||||
className="grouped-status-file-item"
|
||||
title={file.path}
|
||||
>
|
||||
<div className="tree-item-self is-clickable nav-file-title">
|
||||
<div className="tree-item-inner nav-file-title-content grouped-status-file-content">
|
||||
<div className="grouped-status-file-name">
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="grouped-status-file-path">
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="grouped-status-file-name">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="grouped-status-file-path">
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
|
||||
{hasMoreItems && (
|
||||
|
|
|
|||
|
|
@ -46,19 +46,15 @@ export const StatusGroup = ({
|
|||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div className="grouped-status-group tree-item">
|
||||
<div className="grouped-status-group">
|
||||
<div
|
||||
className="grouped-status-group__header tree-item-self is-clickable"
|
||||
className="grouped-status-group__header"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="tree-item-inner grouped-status-group__status">
|
||||
<div className="grouped-status-group__status">
|
||||
<StatusDisplay
|
||||
status={
|
||||
{
|
||||
...status,
|
||||
icon: status.icon || "",
|
||||
lucideIcon: status.lucideIcon,
|
||||
} as NoteStatus
|
||||
{ ...status, icon: status.icon || "" } as NoteStatus
|
||||
}
|
||||
variant="badge"
|
||||
/>
|
||||
|
|
@ -78,16 +74,14 @@ export const StatusGroup = ({
|
|||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="tree-item-children">
|
||||
<FileList
|
||||
files={files}
|
||||
groupKey={groupKey}
|
||||
loadedCount={loadedCount}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</div>
|
||||
<FileList
|
||||
files={files}
|
||||
groupKey={groupKey}
|
||||
loadedCount={loadedCount}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { GroupLabel } from "@/components/atoms/GroupLabel";
|
||||
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
|
||||
import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView";
|
||||
|
|
@ -51,11 +51,6 @@ export const TagSection = ({
|
|||
[onToggleFiles],
|
||||
);
|
||||
|
||||
const statusOrder = useMemo(
|
||||
() => Array.from(statusMap.keys()),
|
||||
[statusMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grouped-status-tag-section">
|
||||
<div className="grouped-status-tag-header" onClick={handleToggle}>
|
||||
|
|
@ -69,38 +64,32 @@ export const TagSection = ({
|
|||
|
||||
{isExpanded && (
|
||||
<div className="grouped-status-tag-content">
|
||||
{Object.entries(statusGroups)
|
||||
.sort(
|
||||
([statusA], [statusB]) =>
|
||||
statusOrder.indexOf(statusA) -
|
||||
statusOrder.indexOf(statusB),
|
||||
)
|
||||
.map(([statusName, files]) => {
|
||||
if (files.length === 0) return null;
|
||||
{Object.entries(statusGroups).map(([statusName, files]) => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const status = statusMap.get(statusName);
|
||||
if (!status) return null;
|
||||
const status = statusMap.get(statusName);
|
||||
if (!status) return null;
|
||||
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
const filesExpanded = expandedFiles.has(groupKey);
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
const groupKey = `${tag}-${statusName}`;
|
||||
const filesExpanded = expandedFiles.has(groupKey);
|
||||
const loadedCount = getLoadedCount(groupKey);
|
||||
|
||||
return (
|
||||
<StatusGroup
|
||||
key={statusName}
|
||||
statusName={statusName}
|
||||
status={status}
|
||||
files={files}
|
||||
tag={tag}
|
||||
isExpanded={filesExpanded}
|
||||
loadedCount={loadedCount}
|
||||
onToggle={() => handleToggleFiles(groupKey)}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<StatusGroup
|
||||
key={statusName}
|
||||
statusName={statusName}
|
||||
status={status}
|
||||
files={files}
|
||||
tag={tag}
|
||||
isExpanded={filesExpanded}
|
||||
loadedCount={loadedCount}
|
||||
onToggle={() => handleToggleFiles(groupKey)}
|
||||
onFileClick={onFileClick}
|
||||
onScroll={onScroll}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
import { FrontmatterMappingsSettings } from "./FrontmatterMappingsSettings";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
|
|
@ -10,51 +8,10 @@ export type Props = {
|
|||
};
|
||||
|
||||
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const availableStatuses = useMemo(() => {
|
||||
return BaseNoteStatusService.getAllAvailableStatuses();
|
||||
}, [
|
||||
settings.templates,
|
||||
settings.customStatuses,
|
||||
settings.enabledTemplates,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Status tag</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Default status for new notes"
|
||||
description="Automatically apply this status to newly created notes."
|
||||
>
|
||||
<select
|
||||
value={settings.defaultStatusForNewNotes || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onChange(
|
||||
"defaultStatusForNewNotes",
|
||||
value === "" ? null : value,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{availableStatuses.map((status) => {
|
||||
const identifier =
|
||||
BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
name: status.name,
|
||||
});
|
||||
const displayName = status.templateId
|
||||
? `${status.name} (${status.templateId})`
|
||||
: status.name;
|
||||
return (
|
||||
<option key={identifier} value={identifier}>
|
||||
{displayName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Enable multiple statuses"
|
||||
description="Allow notes to have multiple statuses at the same time"
|
||||
|
|
@ -68,44 +25,6 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
|
|||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Single status format"
|
||||
description="Choose how the status frontmatter value is stored when multiple statuses are disabled. String format improves compatibility with plugins like TaskNotes."
|
||||
>
|
||||
<select
|
||||
disabled={settings.useMultipleStatuses}
|
||||
value={settings.singleStatusStorageMode || "list"}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"singleStatusStorageMode",
|
||||
e.target
|
||||
.value as PluginSettings["singleStatusStorageMode"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="list">List (status: [in-progress])</option>
|
||||
<option value="string">String (status: in-progress)</option>
|
||||
</select>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Apply status recursively to subfolders"
|
||||
description="Show a folder context menu option that also processes notes inside nested folders."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
settings.applyStatusRecursivelyToSubfolders || false
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"applyStatusRecursivelyToSubfolders",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status tag prefix"
|
||||
description="YAML frontmatter tag name for status (default: obsidian-note-status)"
|
||||
|
|
@ -134,30 +53,6 @@ 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="Write mapped tags to default status tag"
|
||||
description="When enabled, statuses mapped to custom frontmatter keys are also written to the default status tag."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.writeMappedTagsToDefault || false}
|
||||
onChange={(e) =>
|
||||
onChange("writeMappedTagsToDefault", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</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."
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import React from "react";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
import { IconSelectionField } from "./IconSelectionField";
|
||||
|
||||
export type Props = {
|
||||
status: NoteStatus;
|
||||
index: number;
|
||||
onCustomStatusChange: (
|
||||
index: number,
|
||||
column: "name" | "icon" | "color" | "description" | "lucideIcon",
|
||||
column: "name" | "icon" | "color" | "description",
|
||||
value: string,
|
||||
) => void;
|
||||
onCustomStatusRemove: (index: number) => void;
|
||||
|
|
@ -30,28 +28,26 @@ export const CustomStatusItem: React.FC<Props> = ({
|
|||
canMoveDown = false,
|
||||
}) => {
|
||||
const isValid = status.name.trim().length > 0;
|
||||
const displayIcon = status.icon.trim() || "📝";
|
||||
|
||||
return (
|
||||
<div className="custom-status-item">
|
||||
<IconSelectionField
|
||||
className="custom-status-item__icon-fields"
|
||||
emojiValue={status.icon}
|
||||
onEmojiChange={(value) =>
|
||||
onCustomStatusChange(index, "icon", value || "")
|
||||
}
|
||||
emojiLabel="Emoji icon"
|
||||
emojiPlaceholder="Example: ✅ or 🚧"
|
||||
emojiHint="Shown anywhere Lucide is not available."
|
||||
lucideValue={status.lucideIcon || ""}
|
||||
onLucideChange={(value) =>
|
||||
onCustomStatusChange(index, "lucideIcon", value)
|
||||
}
|
||||
lucideLabel="Lucide icon (optional)"
|
||||
lucidePlaceholder="Browse Lucide icons"
|
||||
lucideHint="Matches Obsidian's toolbar icons so your status button blends in."
|
||||
/>
|
||||
|
||||
{/* Simple horizontal layout with remaining inputs */}
|
||||
{/* Simple horizontal layout with all inputs in a row */}
|
||||
<div className="custom-status-item__row">
|
||||
{/* Icon field - simple text input */}
|
||||
<div className="custom-status-item__field">
|
||||
<label className="custom-status-item__label">Icon</label>
|
||||
<Input
|
||||
variant="text"
|
||||
value={status.icon}
|
||||
onChange={(value) =>
|
||||
onCustomStatusChange(index, "icon", value || "")
|
||||
}
|
||||
placeholder="📝"
|
||||
className="custom-status-item__input custom-status-item__input--icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name field */}
|
||||
<div className="custom-status-item__field custom-status-item__field--name">
|
||||
<label className="custom-status-item__label">
|
||||
|
|
@ -143,13 +139,12 @@ export const CustomStatusItem: React.FC<Props> = ({
|
|||
|
||||
{/* Preview row - shows how the status will look */}
|
||||
<div className="custom-status-item__preview">
|
||||
<StatusIcon
|
||||
icon={status.icon}
|
||||
lucideIcon={status.lucideIcon}
|
||||
<span
|
||||
className="custom-status-item__preview-icon"
|
||||
size={18}
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
>
|
||||
{displayIcon}
|
||||
</span>
|
||||
<span
|
||||
className="custom-status-item__preview-text"
|
||||
style={{ color: status.color }}
|
||||
|
|
|
|||
|
|
@ -16,18 +16,17 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const addNewCustomStatus = () => {
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.push({
|
||||
name: "",
|
||||
icon: "",
|
||||
lucideIcon: "",
|
||||
});
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
|
||||
index,
|
||||
) => {
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
currentStatuses.splice(index, 1);
|
||||
onChange("customStatuses", currentStatuses);
|
||||
};
|
||||
|
|
@ -37,7 +36,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
column,
|
||||
value,
|
||||
) => {
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
|
||||
const target = currentStatuses[index];
|
||||
if (target) {
|
||||
|
|
@ -48,7 +47,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
|
||||
const moveCustomStatusUp = (index: number) => {
|
||||
if (index <= 0) return;
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
[currentStatuses[index - 1], currentStatuses[index]] = [
|
||||
currentStatuses[index],
|
||||
currentStatuses[index - 1],
|
||||
|
|
@ -57,8 +56,8 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
};
|
||||
|
||||
const moveCustomStatusDown = (index: number) => {
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
if (index >= currentStatuses.length - 1) return;
|
||||
if (index >= settings.customStatuses.length - 1) return;
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
[currentStatuses[index], currentStatuses[index + 1]] = [
|
||||
currentStatuses[index + 1],
|
||||
currentStatuses[index],
|
||||
|
|
@ -85,11 +84,11 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
|
||||
<SettingItem
|
||||
name="Custom statuses"
|
||||
description="Create custom statuses with emoji icons and optional Lucide icon names plus colors. All statuses require a name."
|
||||
description="Create custom statuses with icons, names, and colors. All statuses require a name."
|
||||
vertical
|
||||
>
|
||||
<div className="custom-status-list">
|
||||
{(settings.customStatuses || []).length === 0 ? (
|
||||
{settings.customStatuses.length === 0 ? (
|
||||
<div className="custom-status-list__empty">
|
||||
<p>
|
||||
No custom statuses yet. Click "Add Status" below
|
||||
|
|
@ -97,7 +96,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(settings.customStatuses || []).map((status, index) => (
|
||||
settings.customStatuses.map((status, index) => (
|
||||
<CustomStatusItem
|
||||
key={index}
|
||||
status={status}
|
||||
|
|
@ -108,8 +107,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
onMoveDown={moveCustomStatusDown}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={
|
||||
index <
|
||||
(settings.customStatuses || []).length - 1
|
||||
index < settings.customStatuses.length - 1
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,310 +0,0 @@
|
|||
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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import React from "react";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
import { LucideIconPicker } from "./LucideIconPicker";
|
||||
|
||||
export interface IconSelectionFieldProps {
|
||||
className?: string;
|
||||
emojiValue: string;
|
||||
onEmojiChange: (value: string) => void;
|
||||
emojiLabel?: string;
|
||||
emojiPlaceholder?: string;
|
||||
emojiHint?: string;
|
||||
lucideValue?: string;
|
||||
onLucideChange?: (value: string) => void;
|
||||
lucideLabel?: string;
|
||||
lucidePlaceholder?: string;
|
||||
lucideHint?: string;
|
||||
lucideAllowClear?: boolean;
|
||||
}
|
||||
|
||||
export const IconSelectionField: React.FC<IconSelectionFieldProps> = ({
|
||||
className = "",
|
||||
emojiValue,
|
||||
onEmojiChange,
|
||||
emojiLabel = "Emoji fallback",
|
||||
emojiPlaceholder = "Example: ✅",
|
||||
emojiHint,
|
||||
lucideValue,
|
||||
onLucideChange,
|
||||
lucideLabel = "Lucide icon",
|
||||
lucidePlaceholder = "Choose Lucide icon",
|
||||
lucideHint,
|
||||
lucideAllowClear = true,
|
||||
}) => {
|
||||
const containerClass = ["icon-selection-field", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className="icon-selection-field__column">
|
||||
<label className="icon-selection-field__label">
|
||||
{emojiLabel}
|
||||
</label>
|
||||
<Input
|
||||
variant="text"
|
||||
value={emojiValue}
|
||||
onChange={onEmojiChange}
|
||||
placeholder={emojiPlaceholder}
|
||||
className="icon-selection-field__emoji-input"
|
||||
/>
|
||||
{emojiHint && (
|
||||
<p className="icon-selection-field__hint">{emojiHint}</p>
|
||||
)}
|
||||
</div>
|
||||
{onLucideChange && (
|
||||
<div className="icon-selection-field__column">
|
||||
<label className="icon-selection-field__label">
|
||||
{lucideLabel}
|
||||
</label>
|
||||
<LucideIconPicker
|
||||
value={lucideValue}
|
||||
onChange={onLucideChange}
|
||||
placeholder={lucidePlaceholder}
|
||||
allowClear={lucideAllowClear}
|
||||
/>
|
||||
{lucideHint && (
|
||||
<p className="icon-selection-field__hint">
|
||||
{lucideHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
import React, {
|
||||
FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { App, Modal } from "obsidian";
|
||||
import { IconName, getIconIds } from "obsidian";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
import { ObsidianIcon } from "@/components/atoms/ObsidianIcon";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
|
||||
const BATCH_SIZE = 80;
|
||||
|
||||
interface LucideIconModalProps {
|
||||
initialValue?: string;
|
||||
onSelect: (value: string) => void;
|
||||
onCloseRequest: () => void;
|
||||
}
|
||||
|
||||
const LucideIconModalContent: FC<LucideIconModalProps> = ({
|
||||
initialValue = "",
|
||||
onSelect,
|
||||
onCloseRequest,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const iconIds = useMemo(() => {
|
||||
try {
|
||||
return getIconIds()
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Lucide icon list", error);
|
||||
return [] as IconName[];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeFuzzyScore = useCallback(
|
||||
(needle: string, haystack: string): number | null => {
|
||||
let lastIndex = -1;
|
||||
let score = 0;
|
||||
|
||||
for (let i = 0; i < needle.length; i += 1) {
|
||||
const char = needle[i];
|
||||
const idx = haystack.indexOf(char, lastIndex + 1);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
score += idx - lastIndex;
|
||||
lastIndex = idx;
|
||||
}
|
||||
|
||||
return score + (haystack.length - lastIndex);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!iconIds.length) {
|
||||
return [];
|
||||
}
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized.length) {
|
||||
return iconIds;
|
||||
}
|
||||
|
||||
const matches = iconIds
|
||||
.map((name) => {
|
||||
const target = name.toLowerCase();
|
||||
const score = computeFuzzyScore(normalized, target);
|
||||
return score === null ? null : { name, score };
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
name: string;
|
||||
score: number;
|
||||
} => entry !== null,
|
||||
)
|
||||
.sort((a, b) => a.score - b.score || a.name.localeCompare(b.name));
|
||||
|
||||
return matches.map((entry) => entry.name);
|
||||
}, [iconIds, query, computeFuzzyScore]);
|
||||
|
||||
const visibleIcons = useMemo(
|
||||
() => filteredIcons.slice(0, visibleCount),
|
||||
[filteredIcons, visibleCount],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(BATCH_SIZE);
|
||||
listRef.current?.scrollTo({ top: 0 });
|
||||
}, [query, filteredIcons.length]);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = event.currentTarget;
|
||||
if (
|
||||
target.scrollTop + target.clientHeight >=
|
||||
target.scrollHeight - 64
|
||||
) {
|
||||
setVisibleCount((prev) =>
|
||||
Math.min(prev + BATCH_SIZE, filteredIcons.length),
|
||||
);
|
||||
}
|
||||
},
|
||||
[filteredIcons.length],
|
||||
);
|
||||
|
||||
const handleSelect = (iconName: string) => {
|
||||
onSelect(iconName);
|
||||
onCloseRequest();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lucide-icon-modal">
|
||||
<div className="lucide-icon-modal__header">
|
||||
<div>
|
||||
<h3 className="modal-title">Select Lucide icon</h3>
|
||||
<p className="lucide-icon-modal__count">
|
||||
Showing {visibleIcons.length} of {filteredIcons.length}{" "}
|
||||
( Total {iconIds.length})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button lucide-icon-modal__close"
|
||||
onClick={onCloseRequest}
|
||||
aria-label="Close"
|
||||
>
|
||||
<ObsidianIcon name="x" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
variant="search"
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search icons…"
|
||||
className="lucide-icon-modal__search"
|
||||
/>
|
||||
|
||||
<label className="lucide-icon-modal__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLabels}
|
||||
onChange={(e) => setShowLabels(e.target.checked)}
|
||||
/>
|
||||
Show icon names
|
||||
</label>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className={`lucide-icon-modal__list${showLabels ? "" : " lucide-icon-modal__list--icons-only"}`}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{visibleIcons.map((iconName) => (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
className={`lucide-icon-modal__option${iconName === initialValue ? " lucide-icon-modal__option--selected" : ""}${showLabels ? "" : " lucide-icon-modal__option--compact"}`}
|
||||
onClick={() => handleSelect(iconName)}
|
||||
>
|
||||
<StatusIcon
|
||||
icon={iconName}
|
||||
lucideIcon={iconName}
|
||||
size={16}
|
||||
/>
|
||||
<span>{iconName}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{visibleIcons.length === 0 && (
|
||||
<div className="lucide-icon-modal__empty">
|
||||
No icons match “{query.trim()}”.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalOptions = {
|
||||
initialValue?: string;
|
||||
onSelect: (value: string) => void;
|
||||
};
|
||||
|
||||
export class LucideIconModal extends Modal {
|
||||
private root: Root | null = null;
|
||||
private readonly options: ModalOptions;
|
||||
|
||||
constructor(app: App, options: ModalOptions) {
|
||||
super(app);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.contentEl.addClass("lucide-icon-modal-wrapper");
|
||||
this.root = createRoot(this.contentEl);
|
||||
this.root.render(
|
||||
<LucideIconModalContent
|
||||
initialValue={this.options.initialValue}
|
||||
onSelect={this.options.onSelect}
|
||||
onCloseRequest={() => this.close()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.root?.unmount();
|
||||
this.root = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import React from "react";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
import { LucideIconModal } from "./LucideIconModal";
|
||||
import { App } from "obsidian";
|
||||
import { Input } from "@/components/atoms/Input";
|
||||
|
||||
const getAppInstance = (): App | undefined => {
|
||||
return (window as typeof window & { app?: App }).app;
|
||||
};
|
||||
|
||||
export interface LucideIconPickerProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
allowTextInput?: boolean;
|
||||
allowClear?: boolean;
|
||||
}
|
||||
|
||||
export const LucideIconPicker: React.FC<LucideIconPickerProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
placeholder = "Choose Lucide icon",
|
||||
allowTextInput = false,
|
||||
allowClear = true,
|
||||
}) => {
|
||||
const openModal = () => {
|
||||
const app = getAppInstance();
|
||||
if (!app) {
|
||||
console.warn("Cannot open Lucide icon modal without Obsidian app");
|
||||
return;
|
||||
}
|
||||
const modal = new LucideIconModal(app, {
|
||||
initialValue: value,
|
||||
onSelect: (icon) => onChange(icon),
|
||||
});
|
||||
modal.open();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lucide-icon-picker">
|
||||
{allowTextInput && (
|
||||
<Input
|
||||
variant="text"
|
||||
value={value}
|
||||
onChange={(val) => onChange(val)}
|
||||
placeholder="Emoji or icon name"
|
||||
className="lucide-icon-picker__text-input"
|
||||
/>
|
||||
)}
|
||||
<div className="lucide-icon-picker__row">
|
||||
<button
|
||||
type="button"
|
||||
className="lucide-icon-picker__modal-button"
|
||||
onClick={openModal}
|
||||
>
|
||||
<span className="lucide-icon-picker__preview">
|
||||
<StatusIcon icon={value} lucideIcon={value} size={18} />
|
||||
</span>
|
||||
<span className="lucide-icon-picker__label">
|
||||
{value || placeholder}
|
||||
</span>
|
||||
</button>
|
||||
{allowClear && value && (
|
||||
<button
|
||||
type="button"
|
||||
className="lucide-icon-picker__clear-trigger"
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import {
|
||||
PREDEFINED_TEMPLATES,
|
||||
COMMUNITY_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 [languageFilter, setLanguageFilter] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
|
||||
const allTemplates = useMemo(() => {
|
||||
return [...PREDEFINED_TEMPLATES, ...COMMUNITY_TEMPLATES];
|
||||
}, []);
|
||||
|
||||
const availableLanguages = useMemo(() => {
|
||||
const langs = new Set<string>();
|
||||
allTemplates.forEach((t) => {
|
||||
if (t.language) langs.add(t.language);
|
||||
});
|
||||
return Array.from(langs).sort();
|
||||
}, [allTemplates]);
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
allTemplates.forEach((t) => {
|
||||
if (t.type) types.add(t.type);
|
||||
});
|
||||
return Array.from(types).sort();
|
||||
}, [allTemplates]);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
return allTemplates.filter((t) => {
|
||||
const matchesSearch =
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesLanguage =
|
||||
!languageFilter || t.language === languageFilter;
|
||||
const matchesType = !typeFilter || t.type === typeFilter;
|
||||
|
||||
return matchesSearch && matchesLanguage && matchesType;
|
||||
});
|
||||
}, [allTemplates, searchQuery, languageFilter, typeFilter]);
|
||||
|
||||
return (
|
||||
<div className="template-editor-modal marketplace-browse-modal">
|
||||
<div className="template-editor-modal__header">
|
||||
<div className="marketplace-header-content">
|
||||
<h2>Template Marketplace</h2>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<SearchFilter
|
||||
value={searchQuery}
|
||||
onFilterChange={setSearchQuery}
|
||||
placeholder="Search templates..."
|
||||
/>
|
||||
{availableLanguages.length > 0 && (
|
||||
<select
|
||||
value={languageFilter}
|
||||
onChange={(e) =>
|
||||
setLanguageFilter(e.target.value)
|
||||
}
|
||||
className="dropdown"
|
||||
>
|
||||
<option value="">All Languages</option>
|
||||
{availableLanguages.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{availableTypes.length > 0 && (
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="dropdown"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{availableTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</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 ${template.isPredefined ? "badge-accent" : "badge-info"}`}
|
||||
>
|
||||
{template.isPredefined
|
||||
? "Built-in"
|
||||
: "Community"}
|
||||
</span>
|
||||
{template.type && (
|
||||
<span className="template-badge badge-neutral">
|
||||
{template.type}
|
||||
</span>
|
||||
)}
|
||||
{template.language && (
|
||||
<span className="template-badge badge-neutral">
|
||||
{template.language}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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> >{" "}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -41,12 +41,7 @@ export const QuickCommandsSettings: React.FC<Props> = ({
|
|||
}
|
||||
}
|
||||
return { customStatuses, templateGroups };
|
||||
}, [
|
||||
settings.useCustomStatusesOnly,
|
||||
settings.customStatuses,
|
||||
settings.enabledTemplates,
|
||||
settings.templates,
|
||||
]);
|
||||
}, [settings]);
|
||||
|
||||
const currentQuickCommands = settings.quickStatusCommands || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
|
||||
export type StatusGroupProps = {
|
||||
statuses: PluginSettings["customStatuses"];
|
||||
|
|
@ -40,12 +39,9 @@ export const StatusGroup: React.FC<StatusGroupProps> = ({
|
|||
>
|
||||
<div className="status-selector__content">
|
||||
<div className="status-selector__status">
|
||||
<StatusIcon
|
||||
icon={status.icon}
|
||||
lucideIcon={status.lucideIcon}
|
||||
className="status-selector__icon"
|
||||
size={16}
|
||||
/>
|
||||
<span className="status-selector__icon">
|
||||
{status.icon}
|
||||
</span>
|
||||
<span className="status-selector__name">
|
||||
{status.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
import { PluginSettings, SyncGroup } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import statusStoreManager from "@/core/statusStoreManager";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const SynchronizationSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleExport = async () => {
|
||||
await settingsService.syncToExternalFile(true);
|
||||
alert("Plugin settings exported successfully.");
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
await settingsService.loadFromExternalFile(true);
|
||||
alert("Plugin settings imported successfully.");
|
||||
};
|
||||
|
||||
const handleDataExport = async () => {
|
||||
try {
|
||||
await statusStoreManager.getNonMarkdownStore().exportToVault();
|
||||
alert("Non-Markdown status data exported to vault.");
|
||||
} catch (e) {
|
||||
alert(`Export failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataImport = async () => {
|
||||
try {
|
||||
await statusStoreManager.getNonMarkdownStore().importFromVault();
|
||||
alert("Non-Markdown status data imported from vault.");
|
||||
} catch (e) {
|
||||
alert(`Import failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (group: SyncGroup) => {
|
||||
const currentGroups = [...(settings.syncGroups || [])];
|
||||
const index = currentGroups.indexOf(group);
|
||||
if (index === -1) {
|
||||
currentGroups.push(group);
|
||||
} else {
|
||||
currentGroups.splice(index, 1);
|
||||
}
|
||||
onChange("syncGroups", currentGroups);
|
||||
};
|
||||
|
||||
const syncGroups: { id: SyncGroup; label: string; description: string }[] =
|
||||
[
|
||||
{
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
description: "Status templates and their enabled status.",
|
||||
},
|
||||
{
|
||||
id: "customStatuses",
|
||||
label: "Custom Statuses",
|
||||
description: "Standalone custom statuses.",
|
||||
},
|
||||
{
|
||||
id: "statusColors",
|
||||
label: "Status Colors",
|
||||
description: "Custom colors for all statuses.",
|
||||
},
|
||||
{
|
||||
id: "uiAppearance",
|
||||
label: "UI & Appearance",
|
||||
description: "Icons, frames, status bar, and toolbar styles.",
|
||||
},
|
||||
{
|
||||
id: "workflow",
|
||||
label: "Workflow Rules",
|
||||
description: "Multi-status, strict mode, and overview popups.",
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
label: "Storage & Tagging",
|
||||
description: "Frontmatter keys, mappings, and tag prefix.",
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
label: "Features & Limits",
|
||||
description: "Quick commands, experiments, and vault limits.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Multi-device Synchronization</h3>
|
||||
<p>
|
||||
Keep your plugin settings and data in sync across devices using
|
||||
files in your vault.
|
||||
</p>
|
||||
|
||||
<div className="sync-section" style={{ marginBottom: "30px" }}>
|
||||
<h4>Settings Synchronization</h4>
|
||||
<p className="setting-item-description">
|
||||
Save plugin configuration to a JSON file.
|
||||
</p>
|
||||
|
||||
<SettingItem
|
||||
name="Enable settings synchronization"
|
||||
description="Automatically save and load selected plugin settings from a file in your vault."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableExternalStatusSync || false}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
"enableExternalStatusSync",
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Settings sync file path"
|
||||
description="Path to the JSON file where settings will be stored (relative to vault root)."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
settings.externalStatusSyncPath ||
|
||||
"_note-status-sync.json"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("externalStatusSyncPath", e.target.value)
|
||||
}
|
||||
placeholder="_note-status-sync.json"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<div
|
||||
className="sync-groups-container"
|
||||
style={{ margin: "20px 0" }}
|
||||
>
|
||||
<h5>Selective Synchronization</h5>
|
||||
<p className="setting-item-description">
|
||||
Choose which groups of settings should be included in
|
||||
the synchronization.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "10px",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
{syncGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`selectable-group-item ${
|
||||
(settings.syncGroups || []).includes(
|
||||
group.id,
|
||||
)
|
||||
? "is-selected"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
padding: "10px",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: (
|
||||
settings.syncGroups || []
|
||||
).includes(group.id)
|
||||
? "var(--background-modifier-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<div style={{ fontWeight: "bold" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(
|
||||
settings.syncGroups || []
|
||||
).includes(group.id)}
|
||||
onChange={() => {}} // Handled by parent div onClick
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
{group.label}
|
||||
</div>
|
||||
<div
|
||||
className="setting-item-description"
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
>
|
||||
{group.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="note-status-settings__actions"
|
||||
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
|
||||
>
|
||||
<button onClick={handleExport}>
|
||||
📤 Export selected now
|
||||
</button>
|
||||
<button onClick={handleImport}>
|
||||
📥 Import selected now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sync-section">
|
||||
<h4>Non-Markdown Data Synchronization</h4>
|
||||
<p className="setting-item-description">
|
||||
Statuses for non-Markdown files (PDFs, images, etc.) are
|
||||
stored in a special data file. Enable this to keep them in
|
||||
sync across devices.
|
||||
</p>
|
||||
|
||||
<SettingItem
|
||||
name="Sync non-Markdown statuses"
|
||||
description="Store non-Markdown status data in a vault file instead of the internal plugin folder."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableNonMarkdownSync || false}
|
||||
onChange={(e) =>
|
||||
onChange("enableNonMarkdownSync", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Data sync file path"
|
||||
description="Path to the JSON file where non-Markdown statuses will be stored."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
settings.nonMarkdownSyncPath ||
|
||||
"_note-status-data.json"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("nonMarkdownSyncPath", e.target.value)
|
||||
}
|
||||
placeholder="_note-status-data.json"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<div
|
||||
className="note-status-settings__actions"
|
||||
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
|
||||
>
|
||||
<button onClick={handleDataExport}>
|
||||
📤 Export internal data to vault
|
||||
</button>
|
||||
<button onClick={handleDataImport}>
|
||||
📥 Import data from vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import React, { useState, useCallback } 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";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
interface TemplateEditorModalProps {
|
||||
template?: StatusTemplate;
|
||||
|
|
@ -18,27 +16,13 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const isCustom = useMemo(
|
||||
() => (template ? isCustomTemplate(template) : true),
|
||||
[template],
|
||||
);
|
||||
const [name, setName] = useState(template?.name || "");
|
||||
const [id, setId] = useState(template?.id || "");
|
||||
const [isIdTouched, setIsIdTouched] = useState(!!template?.id);
|
||||
const [isIdUnlocked, setIsIdUnlocked] = useState(false);
|
||||
const [isAuthorUnlocked, setIsAuthorUnlocked] = useState(false);
|
||||
const [description, setDescription] = useState(template?.description || "");
|
||||
const [language, setLanguage] = useState(template?.language || "");
|
||||
const [type, setType] = useState(template?.type || "");
|
||||
const [authorGithub, setAuthorGithub] = useState(
|
||||
template?.authorGithub || "",
|
||||
);
|
||||
const [statuses, setStatuses] = useState<NoteStatus[]>(
|
||||
template?.statuses || [
|
||||
{
|
||||
name: "",
|
||||
icon: "",
|
||||
lucideIcon: "",
|
||||
color: "#888888",
|
||||
templateId: template?.id || "",
|
||||
},
|
||||
|
|
@ -46,24 +30,15 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
);
|
||||
|
||||
const isEditing = !!template;
|
||||
const isIdUnique =
|
||||
isEditing && id === template?.id
|
||||
? true
|
||||
: !settingsService.settings.templates.some(
|
||||
(t) => t.id === id.trim(),
|
||||
);
|
||||
|
||||
const isValid =
|
||||
name.trim().length > 0 &&
|
||||
id.trim().length > 0 &&
|
||||
isIdUnique &&
|
||||
description.trim().length > 0 &&
|
||||
statuses.some((s) => s.name.trim().length > 0);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!isValid) return;
|
||||
|
||||
const templateId = id.trim();
|
||||
const templateId = template?.id || `custom-${Date.now()}`;
|
||||
const validStatuses = statuses.filter((s) => s.name.trim().length > 0);
|
||||
|
||||
// Update templateId for all statuses
|
||||
|
|
@ -76,39 +51,11 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
id: templateId,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
language: language.trim() || undefined,
|
||||
type: type.trim() || undefined,
|
||||
authorGithub: authorGithub.trim() || undefined,
|
||||
statuses: statusesWithTemplateId,
|
||||
};
|
||||
|
||||
onSave(savedTemplate);
|
||||
}, [
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
language,
|
||||
type,
|
||||
authorGithub,
|
||||
statuses,
|
||||
isValid,
|
||||
onSave,
|
||||
]);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(val: string) => {
|
||||
setName(val);
|
||||
if (!isIdTouched && !isEditing) {
|
||||
setId(val.toLowerCase().replace(/[^a-z0-9]/g, "-"));
|
||||
}
|
||||
},
|
||||
[isIdTouched, isEditing],
|
||||
);
|
||||
|
||||
const handleIdChange = useCallback((val: string) => {
|
||||
setId(val.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||
setIsIdTouched(true);
|
||||
}, []);
|
||||
}, [name, description, statuses, template, isValid, onSave]);
|
||||
|
||||
const handleAddStatus = useCallback(() => {
|
||||
setStatuses((prev) => [
|
||||
|
|
@ -116,7 +63,6 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
{
|
||||
name: "",
|
||||
icon: "",
|
||||
lucideIcon: "",
|
||||
color: "#888888",
|
||||
templateId: template?.id || "",
|
||||
},
|
||||
|
|
@ -126,7 +72,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
const handleStatusChange = useCallback(
|
||||
(
|
||||
index: number,
|
||||
column: "name" | "icon" | "color" | "description" | "lucideIcon",
|
||||
column: "name" | "icon" | "color" | "description",
|
||||
value: string,
|
||||
) => {
|
||||
setStatuses((prev) =>
|
||||
|
|
@ -180,46 +126,11 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
<Input
|
||||
variant="text"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
onChange={setName}
|
||||
placeholder="e.g. Project Workflow"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Template ID"
|
||||
description={
|
||||
"A unique identifier for your template (used in the frontmatter settings). Cannot contain spaces." +
|
||||
(!isIdUnique && id.trim().length > 0
|
||||
? " (This ID is already taken!)"
|
||||
: "") +
|
||||
(isEditing && !isIdUnlocked
|
||||
? " Warning: Changing this ID will detach it from existing notes using it."
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={id}
|
||||
onChange={handleIdChange}
|
||||
placeholder="e.g. project-workflow"
|
||||
disabled={isEditing && !isIdUnlocked}
|
||||
/>
|
||||
{isEditing && !isIdUnlocked && (
|
||||
<button onClick={() => setIsIdUnlocked(true)}>
|
||||
Unlock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Description"
|
||||
description="Brief description of the template's purpose"
|
||||
|
|
@ -232,64 +143,9 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
|
|||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Template Type (Optional)"
|
||||
description="Categorize your template (e.g. Academic, Project Management, Writing, General)"
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={type}
|
||||
onChange={setType}
|
||||
placeholder="e.g. Project Management"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Language (Optional)"
|
||||
description="Primary language of the statuses in this template"
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={language}
|
||||
onChange={setLanguage}
|
||||
placeholder="e.g. English"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Author GitHub Username (Optional)"
|
||||
description={
|
||||
isCustom
|
||||
? "Your GitHub username for attribution"
|
||||
: "Original author of the marketplace template"
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={authorGithub}
|
||||
onChange={setAuthorGithub}
|
||||
placeholder="e.g. janedoe"
|
||||
disabled={!isCustom && !isAuthorUnlocked}
|
||||
/>
|
||||
{!isCustom && !isAuthorUnlocked && (
|
||||
<button onClick={() => setIsAuthorUnlocked(true)}>
|
||||
Unlock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Statuses"
|
||||
description="Define the statuses included in this template, including emojis and optional Lucide icons"
|
||||
description="Define the statuses included in this template"
|
||||
vertical
|
||||
>
|
||||
<div className="template-editor-statuses">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { StatusDisplay } from "../atoms/StatusDisplay";
|
||||
import { SelectableListItem } from "../atoms/SelectableListItem";
|
||||
import { isCustomTemplate, isTemplateModified } from "@/utils/templateUtils";
|
||||
import { isCustomTemplate } from "@/utils/templateUtils";
|
||||
import { ObsidianIcon } from "../atoms/ObsidianIcon";
|
||||
|
||||
interface TemplateItemProps {
|
||||
|
|
@ -11,7 +11,6 @@ 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> = ({
|
||||
|
|
@ -20,14 +19,7 @@ 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")) {
|
||||
|
|
@ -37,7 +29,7 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`template-item ${isEnabled ? "enabled" : "disabled"}`}>
|
||||
<div className={`template-item ${isEnabled ? "enabled" : ""}`}>
|
||||
<SelectableListItem
|
||||
selected={isEnabled}
|
||||
onClick={handleClick}
|
||||
|
|
@ -56,47 +48,14 @@ export const TemplateItem: React.FC<TemplateItemProps> = ({
|
|||
<span className="setting-item-name">
|
||||
{template.name}
|
||||
</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>
|
||||
{isCustomTemplate(template.id) && (
|
||||
<span className="template-custom-badge">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="setting-item-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>
|
||||
)}
|
||||
{template.description}:
|
||||
</div>
|
||||
<div className="template-statuses">
|
||||
{template.statuses.map((status, index) => (
|
||||
|
|
@ -110,18 +69,6 @@ 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) => {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ 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;
|
||||
|
|
@ -20,18 +21,13 @@ 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) => {
|
||||
let newEnabledTemplates = [...(settings.enabledTemplates || [])];
|
||||
let newEnabledTemplates = [...settings.enabledTemplates];
|
||||
if (enabled) {
|
||||
if (!newEnabledTemplates.includes(templateId)) {
|
||||
newEnabledTemplates.push(templateId);
|
||||
|
|
@ -56,43 +52,6 @@ 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
|
||||
|
|
@ -113,16 +72,8 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
|
|||
);
|
||||
onChange("templates", updatedTemplates);
|
||||
} else {
|
||||
// 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);
|
||||
|
||||
// Add new template - generate unique ID
|
||||
const uniqueId = generateTemplateId(template.name);
|
||||
finalTemplate = { ...template, id: uniqueId };
|
||||
|
||||
// Update statuses with the final template ID
|
||||
|
|
@ -167,24 +118,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>
|
||||
|
|
@ -198,68 +149,43 @@ 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>
|
||||
Browse the marketplace to find common status workflows or create
|
||||
your own custom templates.
|
||||
Enable predefined templates to quickly add 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 marketplace-browse-btn"
|
||||
onClick={handleOpenMarketplace}
|
||||
>
|
||||
<ObsidianIcon name="globe" size={16} />
|
||||
Browse Marketplace
|
||||
</button>
|
||||
<button
|
||||
className="template-create-btn"
|
||||
className="mod-cta 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) => (
|
||||
{settings.templates.map((template) => (
|
||||
<TemplateItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isEnabled={(
|
||||
settings.enabledTemplates || []
|
||||
).includes(template.id)}
|
||||
isEnabled={settings.enabledTemplates.includes(
|
||||
template.id,
|
||||
)}
|
||||
onToggle={handleTemplateToggle}
|
||||
onEdit={handleEditTemplate}
|
||||
onDelete={handleDeleteTemplate}
|
||||
onShare={handleShareTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,152 @@
|
|||
import React from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import React from "react";
|
||||
import { Input } from "../atoms/Input";
|
||||
import { Select } from "../atoms/Select";
|
||||
import { SettingItem } from "./SettingItem";
|
||||
import { IconSelectionField } from "./IconSelectionField";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const StatusBarSettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const handleCheckbox =
|
||||
export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const handleChange =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(key, e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ui-settings__group">
|
||||
<h3>Status Bar</h3>
|
||||
<div className="ui-settings">
|
||||
<h3>User interface</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Show status icon in status bar"
|
||||
description="Display a status icon in the status bar with the current note status."
|
||||
name="Show status bar"
|
||||
description="Display the status bar"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusBar}
|
||||
onChange={handleCheckbox("showStatusBar")}
|
||||
onChange={handleChange("showStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Hide icon when note has no status"
|
||||
description="Automatically hide the status icon whenever the current note does not have a status."
|
||||
name="Auto-hide status bar"
|
||||
description="Hide the status bar when status is unknown"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoHideStatusBar}
|
||||
onChange={handleCheckbox("autoHideStatusBar")}
|
||||
onChange={handleChange("autoHideStatusBar")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show status overview popup"
|
||||
description="Show a popup listing statuses when hovering or interacting with the status icon."
|
||||
name="Show status icons in file explorer"
|
||||
description="Display status icons in the file explorer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableStatusOverviewPopup ?? true}
|
||||
onChange={handleCheckbox("enableStatusOverviewPopup")}
|
||||
checked={settings.showStatusIconsInExplorer}
|
||||
onChange={handleChange("showStatusIconsInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show template names next to status"
|
||||
description="Control how the template name is shown alongside the current status."
|
||||
name="Status icon in file explorer position"
|
||||
description="Choose icon position in file explorer"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ display: "Filename left", value: "file-name-left" },
|
||||
{ display: "Filename right", value: "file-name-right" },
|
||||
{ display: "Absolute right", value: "absolute-right" },
|
||||
]}
|
||||
defaultValue={settings.fileExplorerIconPosition}
|
||||
onChange={(value) =>
|
||||
onChange("fileExplorerIconPosition", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Hide unknown status in file explorer"
|
||||
description="Hide status icons for files with unknown status"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.hideUnknownStatusInExplorer || false}
|
||||
onChange={handleChange("hideUnknownStatusInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<h4>Unknown Status Customization</h4>
|
||||
|
||||
<SettingItem
|
||||
name="Unknown status icon"
|
||||
description="Icon displayed for files with unknown status"
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={settings.unknownStatusIcon}
|
||||
onChange={(value) => onChange("unknownStatusIcon", value)}
|
||||
placeholder="❓"
|
||||
style={{ maxWidth: "150px" }}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Unknown status color"
|
||||
description="Hex color for unknown status (e.g., #8b949e)"
|
||||
>
|
||||
<Input
|
||||
variant="color"
|
||||
value={settings.unknownStatusColor || "#8b949e"}
|
||||
onChange={(value) => onChange("unknownStatusColor", value)}
|
||||
placeholder="No status"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status bar 'no status' text"
|
||||
description="Text displayed in status bar when there is no status"
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={settings.statusBarNoStatusText}
|
||||
onChange={(value) =>
|
||||
onChange("statusBarNoStatusText", value)
|
||||
}
|
||||
style={{ maxWidth: "150px" }}
|
||||
placeholder="No status"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show icon in status bar for 'no status'"
|
||||
description="Show unknown status icon in status bar alongside text"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.statusBarShowNoStatusIcon || false}
|
||||
onChange={handleChange("statusBarShowNoStatusIcon")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show text in status bar for 'no status'"
|
||||
description="Display the custom text in the status bar when there is no status"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.statusBarShowNoStatusText ?? true}
|
||||
onChange={handleChange("statusBarShowNoStatusText")}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
name="Show template names in status bar"
|
||||
description="Control how template names are displayed alongside status names"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
|
|
@ -79,117 +170,29 @@ export const StatusBarSettings: React.FC<Props> = ({ settings, onChange }) => {
|
|||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status bar badge style"
|
||||
description="Match the file explorer visuals: accent stripe, solid fill, or dot marker."
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "accent", display: "Accent stripe (default)" },
|
||||
{ value: "filled", display: "Solid background" },
|
||||
{ value: "dot", display: "Dot marker" },
|
||||
]}
|
||||
defaultValue={settings.statusBarBadgeStyle || "accent"}
|
||||
onChange={(value) => onChange("statusBarBadgeStyle", value)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status bar badge content"
|
||||
description="Choose whether the badge shows icon, text, both, or an empty accent."
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: "icon-text",
|
||||
display: "Icon + text (default)",
|
||||
},
|
||||
{ value: "icon", display: "Icon only" },
|
||||
{ value: "text", display: "Text only" },
|
||||
{ value: "none", display: "Empty badge (accent only)" },
|
||||
]}
|
||||
defaultValue={
|
||||
settings.statusBarBadgeContentMode || "icon-text"
|
||||
}
|
||||
onChange={(value) =>
|
||||
onChange("statusBarBadgeContentMode", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<h4>No Status Display</h4>
|
||||
|
||||
<SettingItem
|
||||
name="Custom text when note has no status"
|
||||
description="Pick what text should be shown in the status bar when a note does not have a status."
|
||||
>
|
||||
<Input
|
||||
variant="text"
|
||||
value={settings.statusBarNoStatusText}
|
||||
onChange={(value) =>
|
||||
onChange("statusBarNoStatusText", value)
|
||||
}
|
||||
className="ui-settings__no-status-input"
|
||||
placeholder="No status"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show icon when note has no status"
|
||||
description="Display the unknown status icon in the status bar next to the custom text."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.statusBarShowNoStatusIcon || false}
|
||||
onChange={handleCheckbox("statusBarShowNoStatusIcon")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show text when note has no status"
|
||||
description="Display the custom text in the status bar whenever no status is set."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.statusBarShowNoStatusText ?? true}
|
||||
onChange={handleCheckbox("statusBarShowNoStatusText")}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditorToolbarSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleCheckbox =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(key, e.target.checked);
|
||||
|
||||
return (
|
||||
<div className="ui-settings__group">
|
||||
<h3>Editor Toolbar</h3>
|
||||
<h4>Editor Toolbar Button</h4>
|
||||
|
||||
<SettingItem
|
||||
name="Show editor toolbar button"
|
||||
description="Add a button to the editor toolbar to change the note status."
|
||||
description="Display status button in the editor toolbar"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showEditorToolbarButton ?? true}
|
||||
onChange={handleCheckbox("showEditorToolbarButton")}
|
||||
onChange={handleChange("showEditorToolbarButton")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Editor toolbar button position"
|
||||
description="Choose where the status button should appear in the editor toolbar."
|
||||
description="Choose where to position the status button in the editor toolbar"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "left", display: "Left side of toolbar" },
|
||||
{
|
||||
value: "left",
|
||||
display: "Left side of toolbar",
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
display: "Right side (after all buttons)",
|
||||
|
|
@ -209,13 +212,19 @@ export const EditorToolbarSettings: React.FC<Props> = ({
|
|||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show toolbar button for"
|
||||
description="Control which editors should display the toolbar button."
|
||||
name="Editor toolbar button display"
|
||||
description="Control which notes show the status button"
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "all-notes", display: "All open notes" },
|
||||
{ value: "active-only", display: "Active note only" },
|
||||
{
|
||||
value: "all-notes",
|
||||
display: "All open notes",
|
||||
},
|
||||
{
|
||||
value: "active-only",
|
||||
display: "Active note only",
|
||||
},
|
||||
]}
|
||||
defaultValue={
|
||||
settings.editorToolbarButtonDisplay || "all-notes"
|
||||
|
|
@ -228,243 +237,3 @@ export const EditorToolbarSettings: React.FC<Props> = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileExplorerSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleCheckbox =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(key, e.target.checked);
|
||||
|
||||
return (
|
||||
<div className="ui-settings__group">
|
||||
<h3>File Explorer</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Show status icons in file explorer"
|
||||
description="Display a status icon for each file inside the file explorer."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showStatusIconsInExplorer}
|
||||
onChange={handleCheckbox("showStatusIconsInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status icon position in file explorer"
|
||||
description="Choose where the icon should be rendered next to the file name."
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ display: "Filename left", value: "file-name-left" },
|
||||
{ display: "Filename right", value: "file-name-right" },
|
||||
{ display: "Absolute right", value: "absolute-right" },
|
||||
]}
|
||||
defaultValue={settings.fileExplorerIconPosition}
|
||||
onChange={(value) =>
|
||||
onChange("fileExplorerIconPosition", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Hide icon when file status is unknown"
|
||||
description="Do not render a status icon in the explorer when a note status cannot be determined."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.hideUnknownStatusInExplorer || false}
|
||||
onChange={handleCheckbox("hideUnknownStatusInExplorer")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Color filename text using status color"
|
||||
description="Apply the active status color to the filename text inside the file explorer."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fileExplorerColorFileName || false}
|
||||
onChange={handleCheckbox("fileExplorerColorFileName")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Color the file explorer block based on status"
|
||||
description="Tint the entire file explorer list item with the active status color."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fileExplorerColorBlock || false}
|
||||
onChange={handleCheckbox("fileExplorerColorBlock")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<h4>Additional status visualization in file explorer</h4>
|
||||
|
||||
<SettingItem
|
||||
name="Show colored left border"
|
||||
description="Render a subtle colored strip on the left edge of the file explorer item."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fileExplorerLeftBorder || false}
|
||||
onChange={handleCheckbox("fileExplorerLeftBorder")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Show colored status dot"
|
||||
description="Append a compact colored dot next to the filename as an extra status indicator."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fileExplorerStatusDot || false}
|
||||
onChange={handleCheckbox("fileExplorerStatusDot")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Underline filename with status color"
|
||||
description="Adds a colored underline below the filename without changing the text color."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fileExplorerUnderlineFileName || false}
|
||||
onChange={handleCheckbox("fileExplorerUnderlineFileName")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status icon frame"
|
||||
description="Choose whether to display a frame around the status icon inside the file explorer."
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "never", display: "Never show a frame" },
|
||||
{ value: "always", display: "Always show a frame" },
|
||||
]}
|
||||
defaultValue={settings.fileExplorerIconFrame || "never"}
|
||||
onChange={(value) =>
|
||||
onChange("fileExplorerIconFrame", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Status icon color"
|
||||
description="Decide whether icons use their configured status colors or inherit the theme default."
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
value: "status",
|
||||
display: "Use custom status colors",
|
||||
},
|
||||
{ value: "theme", display: "Use theme default colors" },
|
||||
]}
|
||||
defaultValue={
|
||||
settings.fileExplorerIconColorMode || "status"
|
||||
}
|
||||
onChange={(value) =>
|
||||
onChange("fileExplorerIconColorMode", value)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnknownStatusAppearanceSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="ui-settings__group">
|
||||
<h3>Unknown / No-Status Appearance</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Icon for unknown status"
|
||||
description="Emoji or Lucide icon name displayed whenever a note does not have a status."
|
||||
vertical
|
||||
>
|
||||
<IconSelectionField
|
||||
emojiValue={settings.unknownStatusIcon}
|
||||
onEmojiChange={(value) => onChange("unknownStatusIcon", value)}
|
||||
emojiPlaceholder="❓"
|
||||
emojiHint="Type any emoji or text fallback."
|
||||
lucideValue={settings.unknownStatusLucideIcon || ""}
|
||||
onLucideChange={(value) =>
|
||||
onChange("unknownStatusLucideIcon", value)
|
||||
}
|
||||
lucidePlaceholder="Choose Lucide icon"
|
||||
lucideHint="Matches the Obsidian toolbar style."
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Color for unknown status"
|
||||
description="Hex color used for unknown statuses (e.g., #8b949e)."
|
||||
>
|
||||
<Input
|
||||
variant="color"
|
||||
value={settings.unknownStatusColor || "#8b949e"}
|
||||
onChange={(value) => onChange("unknownStatusColor", value)}
|
||||
placeholder="No status"
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ExperimentalSettings: React.FC<Props> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleCheckbox =
|
||||
(key: keyof PluginSettings) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(key, e.target.checked);
|
||||
|
||||
return (
|
||||
<div className="ui-settings__group">
|
||||
<h3>Experimental features</h3>
|
||||
|
||||
<SettingItem
|
||||
name="Enable experimental features"
|
||||
description="Unlock beta views that are still under development."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableExperimentalFeatures || false}
|
||||
onChange={handleCheckbox("enableExperimentalFeatures")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Enable status dashboard"
|
||||
description="Show the ribbon shortcut to open the status dashboard view."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!settings.enableExperimentalFeatures}
|
||||
checked={settings.enableStatusDashboard || false}
|
||||
onChange={handleCheckbox("enableStatusDashboard")}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Enable grouped status view"
|
||||
description="Show the ribbon shortcut to open the grouped status view."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!settings.enableExperimentalFeatures}
|
||||
checked={settings.enableGroupedStatusView || false}
|
||||
onChange={handleCheckbox("enableGroupedStatusView")}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,47 +1,18 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Notice } from "obsidian";
|
||||
import { useState, useEffect } from "react";
|
||||
import { PluginSettings } from "@/types/pluginSettings";
|
||||
import { UISettings } from "./UISettings";
|
||||
import { TemplateSettings } from "./TemplateSettings";
|
||||
import { BehaviourSettings } from "./BehaviourSettings";
|
||||
import { CustomStatusSettings } from "./CustomStatusSettings";
|
||||
import { QuickCommandsSettings } from "./QuickCommandsSettings";
|
||||
import { SynchronizationSettings } from "./SynchronizationSettings";
|
||||
import {
|
||||
EditorToolbarSettings,
|
||||
ExperimentalSettings,
|
||||
FileExplorerSettings,
|
||||
StatusBarSettings,
|
||||
UnknownStatusAppearanceSettings,
|
||||
} from "./UISettings";
|
||||
|
||||
export type Props = {
|
||||
settings: PluginSettings;
|
||||
onChange: (key: keyof PluginSettings, value: unknown) => void;
|
||||
};
|
||||
|
||||
type SectionId =
|
||||
| "templates-statuses"
|
||||
| "quick-actions"
|
||||
| "status-bar"
|
||||
| "editor-toolbar"
|
||||
| "file-explorer"
|
||||
| "unknown-appearance"
|
||||
| "behavior-storage"
|
||||
| "synchronization"
|
||||
| "experimental-features";
|
||||
|
||||
type SectionDefinition = {
|
||||
id: SectionId;
|
||||
label: string;
|
||||
description: string;
|
||||
content: React.ReactNode;
|
||||
};
|
||||
|
||||
const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
|
||||
const [localSettings, setLocalSettings] = useState(settings);
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
Partial<Record<SectionId, boolean>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings);
|
||||
|
|
@ -52,239 +23,16 @@ const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
|
|||
onChange(key, value);
|
||||
};
|
||||
|
||||
const handleSupportClick = (url: string, label: string) => {
|
||||
window.open(url);
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
new Notice(`Copied ${label} URL to clipboard!`);
|
||||
});
|
||||
};
|
||||
|
||||
const sections: SectionDefinition[] = [
|
||||
{
|
||||
id: "templates-statuses",
|
||||
label: "Templates & Statuses",
|
||||
description:
|
||||
"Enable predefined workflows or define your own statuses.",
|
||||
content: (
|
||||
<div className="note-status-settings__stack">
|
||||
<TemplateSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<CustomStatusSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "quick-actions",
|
||||
label: "Quick Actions",
|
||||
description: "Choose statuses with command palette shortcuts.",
|
||||
content: (
|
||||
<QuickCommandsSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status-bar",
|
||||
label: "Status Bar",
|
||||
description:
|
||||
"Icon, popup, badge style/content, and no-status behavior.",
|
||||
content: (
|
||||
<StatusBarSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "editor-toolbar",
|
||||
label: "Editor Toolbar",
|
||||
description: "Button placement and visibility across panes.",
|
||||
content: (
|
||||
<EditorToolbarSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "file-explorer",
|
||||
label: "File Explorer",
|
||||
description:
|
||||
"Icon placement, hiding unknown, and visual styling options.",
|
||||
content: (
|
||||
<FileExplorerSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "unknown-appearance",
|
||||
label: "Unknown / No-Status Appearance",
|
||||
description: "Fallback icon and color when a note lacks status.",
|
||||
content: (
|
||||
<UnknownStatusAppearanceSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "behavior-storage",
|
||||
label: "Behavior & Storage",
|
||||
description:
|
||||
"Multi-status mode, tag key, mappings, and vault safeguards.",
|
||||
content: (
|
||||
<BehaviourSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "synchronization",
|
||||
label: "Synchronization",
|
||||
description:
|
||||
"Keep custom statuses and templates in sync across multiple devices.",
|
||||
content: (
|
||||
<SynchronizationSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "experimental-features",
|
||||
label: "Experimental Features",
|
||||
description: "Opt into dashboard and grouped view previews.",
|
||||
content: (
|
||||
<ExperimentalSettings
|
||||
settings={localSettings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="note-status-settings">
|
||||
{sections.map((section) => {
|
||||
const isOpen = expandedSections[section.id] ?? false;
|
||||
return (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`note-status-section ${
|
||||
isOpen ? "is-open" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="note-status-section__header"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() =>
|
||||
setExpandedSections((prev) => ({
|
||||
...prev,
|
||||
[section.id]: !prev[section.id],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="note-status-section__title-block">
|
||||
<div className="setting-item-name">
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="setting-item-description">
|
||||
{section.description}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="note-status-section__chevron"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isOpen ? "v" : ">"}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="note-status-section__content">
|
||||
{section.content}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="note-status-settings__intro">
|
||||
<p className="note-status-settings__description">
|
||||
Note Status keeps your vault aligned with clear,
|
||||
configurable workflows. Tune the sections below to match how
|
||||
you review notes, surface statuses, and keep storage tidy.
|
||||
</p>
|
||||
<div className="note-status-settings__support">
|
||||
<button
|
||||
className="note-status-settings__support-link"
|
||||
onClick={() =>
|
||||
handleSupportClick(
|
||||
"https://paypal.me/aleixsoler",
|
||||
"PayPal",
|
||||
)
|
||||
}
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white"
|
||||
alt="PayPal"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<span className="note-status-settings__support-link-label">
|
||||
Support via PayPal
|
||||
</span>
|
||||
<span className="note-status-settings__support-link-url">
|
||||
paypal.me/aleixsoler
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="note-status-settings__support-link"
|
||||
onClick={() =>
|
||||
handleSupportClick(
|
||||
"https://buymeacoffee.com/aleixsoler",
|
||||
"Buy Me a Coffee",
|
||||
)
|
||||
}
|
||||
>
|
||||
<img
|
||||
src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black"
|
||||
alt="Buy me a coffee"
|
||||
style={{ height: "20px" }}
|
||||
/>
|
||||
<span className="note-status-settings__support-link-label">
|
||||
Buy me a coffee
|
||||
</span>
|
||||
<span className="note-status-settings__support-link-url">
|
||||
buymeacoffee.com/aleixsoler
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="note-status-settings__support-link"
|
||||
onClick={() =>
|
||||
handleSupportClick(
|
||||
"https://github.com/devonthesofa/obsidian-note-status",
|
||||
"GitHub",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="note-status-settings__support-link-label">
|
||||
Star on GitHub
|
||||
</span>
|
||||
<span className="note-status-settings__support-link-url">
|
||||
github.com/devonthesofa/obsidian-note-status
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateSettings settings={settings} onChange={handleChange} />
|
||||
<UISettings settings={localSettings} onChange={handleChange} />
|
||||
<BehaviourSettings settings={settings} onChange={handleChange} />
|
||||
<CustomStatusSettings settings={settings} onChange={handleChange} />
|
||||
<QuickCommandsSettings
|
||||
settings={settings}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { CSSProperties, FC } from "react";
|
||||
import { FC } from "react";
|
||||
import {
|
||||
StatusBarProvider,
|
||||
Props as StatusBarContextProps,
|
||||
} from "./StatusBarContext";
|
||||
import { StatusBarGroup } from "./StatusBarGroup";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
|
||||
export type Props = {
|
||||
statuses: GroupedStatuses;
|
||||
|
|
@ -13,14 +12,11 @@ export type Props = {
|
|||
hideIfNotStatuses?: boolean;
|
||||
onStatusClick: StatusBarContextProps["onStatusClick"];
|
||||
templateNameMode?: "always" | "never" | "auto";
|
||||
badgeStyle?: "accent" | "filled" | "dot";
|
||||
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
|
||||
noStatusConfig?: {
|
||||
text: string;
|
||||
showIcon: boolean;
|
||||
showText: boolean;
|
||||
icon: string;
|
||||
lucideIcon?: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -30,8 +26,6 @@ export const StatusBar: FC<Props> = ({
|
|||
hideIfNotStatuses,
|
||||
onStatusClick,
|
||||
templateNameMode = "auto",
|
||||
badgeStyle = "accent",
|
||||
badgeContentMode = "icon-text",
|
||||
noStatusConfig,
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
|
|
@ -49,29 +43,23 @@ export const StatusBar: FC<Props> = ({
|
|||
|
||||
return (
|
||||
<span
|
||||
className="status-bar-item status-bar-no-status mod-clickable"
|
||||
className="status-bar-item mod-clickable"
|
||||
onClick={() => onStatusClick({ name: "", icon: "" })}
|
||||
style={
|
||||
noStatusConfig.color
|
||||
? ({
|
||||
"--status-empty-color": noStatusConfig.color,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: noStatusConfig.color,
|
||||
}}
|
||||
>
|
||||
{noStatusConfig.showIcon && (
|
||||
<StatusIcon
|
||||
icon={noStatusConfig.icon}
|
||||
lucideIcon={noStatusConfig.lucideIcon}
|
||||
size={14}
|
||||
className="status-bar-no-status__icon"
|
||||
/>
|
||||
)}
|
||||
{noStatusConfig.showText && (
|
||||
<span className="status-bar-no-status__label">
|
||||
{noStatusConfig.text}
|
||||
<span
|
||||
style={{
|
||||
marginRight: noStatusConfig.showText ? "4px" : "0",
|
||||
}}
|
||||
>
|
||||
{noStatusConfig.icon}
|
||||
</span>
|
||||
)}
|
||||
{noStatusConfig.showText && noStatusConfig.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,8 +72,6 @@ export const StatusBar: FC<Props> = ({
|
|||
key={frontmatterTagName}
|
||||
statuses={statusList}
|
||||
templateNameMode={templateNameMode}
|
||||
badgeStyle={badgeStyle}
|
||||
badgeContentMode={badgeContentMode}
|
||||
template={{
|
||||
description: "Note status",
|
||||
name: "",
|
||||
|
|
|
|||
17
components/StatusBar/StatusBarEnableButton.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { FC } from "react";
|
||||
|
||||
export type Props = {
|
||||
onEnableClick: () => void;
|
||||
};
|
||||
|
||||
export const StatusBarEnableButton: FC<Props> = ({ onEnableClick }) => {
|
||||
return (
|
||||
<span
|
||||
className="status-bar-item-icon status-bar-enable-button"
|
||||
data-tooltip-position="top"
|
||||
onClick={onEnableClick}
|
||||
>
|
||||
📊
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { CSSProperties, FC, useState } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { StatusTemplate } from "@/types/pluginSettings";
|
||||
import { GroupLabel } from "../atoms/GroupLabel";
|
||||
|
|
@ -11,8 +11,6 @@ export interface StatusBarGroupProps {
|
|||
template: StatusTemplate;
|
||||
maxVisible?: number;
|
||||
templateNameMode?: "always" | "never" | "auto";
|
||||
badgeStyle?: "accent" | "filled" | "dot";
|
||||
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
|
||||
}
|
||||
|
||||
export const StatusBarGroup: FC<StatusBarGroupProps> = ({
|
||||
|
|
@ -20,8 +18,6 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
|
|||
template,
|
||||
maxVisible = 3,
|
||||
templateNameMode = "auto",
|
||||
badgeStyle = "accent",
|
||||
badgeContentMode = "icon-text",
|
||||
}) => {
|
||||
const [isUncollapsed, setIsUncollapsed] = useState(false);
|
||||
const visibleStatuses = isUncollapsed
|
||||
|
|
@ -40,24 +36,19 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
|
|||
<GroupLabel name={template.name} isHighlighted={isUncollapsed} />
|
||||
<div className="status-bar-group-container">
|
||||
{visibleStatuses.map((status, i) => (
|
||||
// Use a wrapper so we can animate statuses when expanding
|
||||
<div
|
||||
key={i}
|
||||
className={`status-bar-status-item${isUncollapsed && i >= maxVisible ? " status-bar-status-item--animated" : ""}`}
|
||||
style={
|
||||
isUncollapsed && i >= maxVisible
|
||||
? ({
|
||||
"--status-anim-delay": `${i * 0.05}s`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
animation:
|
||||
isUncollapsed && i >= maxVisible
|
||||
? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both`
|
||||
: "none",
|
||||
}}
|
||||
title={status.description}
|
||||
>
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
variant="badge"
|
||||
badgeStyle={badgeStyle}
|
||||
badgeContentMode={badgeContentMode}
|
||||
templateNameMode={templateNameMode}
|
||||
hasNameConflicts={getHasConflicts(status)}
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
|
||||
|
||||
interface CurrentNoteInfo {
|
||||
file: TFile | null;
|
||||
|
|
@ -31,18 +30,9 @@ export const useCurrentNote = () => {
|
|||
const noteStatusService = new NoteStatusService(activeFile);
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusesByKey: Record<string, NoteStatus[]> = {};
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statuses = noteStatusService.getStatusesForKey(key);
|
||||
if (statuses.length) {
|
||||
statusesByKey[key] = statuses;
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentNote({
|
||||
file: activeFile,
|
||||
statuses: statusesByKey,
|
||||
statuses: noteStatusService.statuses,
|
||||
lastModified: activeFile.stat.mtime,
|
||||
});
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
export interface VaultStats {
|
||||
totalNotes: number;
|
||||
|
|
@ -30,10 +27,10 @@ export const useVaultStats = () => {
|
|||
});
|
||||
|
||||
const calculateVaultStats = useCallback((): VaultStats => {
|
||||
const files = BaseNoteStatusService.app.vault.getFiles();
|
||||
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
|
||||
let notesWithStatus = 0;
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
|
|
@ -52,26 +49,50 @@ export const useVaultStats = () => {
|
|||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
let hasAnyStatus = false;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statuses = noteStatusService.getStatusesForKey(key);
|
||||
if (!statuses.length) return;
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
|
||||
statuses.forEach((status) => {
|
||||
const scopedIdentifier = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
// Determine the scoped identifier to use
|
||||
let scopedIdentifier: string;
|
||||
|
||||
if (statusStr.includes(":")) {
|
||||
// Already scoped
|
||||
scopedIdentifier = statusStr;
|
||||
} else {
|
||||
// Legacy status - find first template that has this status
|
||||
const firstTemplateWithStatus =
|
||||
availableStatuses.find(
|
||||
(s) => s.name === statusStr && s.templateId,
|
||||
);
|
||||
scopedIdentifier = firstTemplateWithStatus
|
||||
? `${firstTemplateWithStatus.templateId}:${statusStr}`
|
||||
: statusStr; // Fallback to unscoped if no template found
|
||||
}
|
||||
|
||||
// Initialize status if not already present
|
||||
if (
|
||||
!statusDistribution.hasOwnProperty(scopedIdentifier)
|
||||
) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyStatus) {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,11 @@ import { StatusDisplay } from "../atoms/StatusDisplay";
|
|||
|
||||
export interface Props {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const StatusFileInfoPopup: React.FC<Props> = ({
|
||||
statuses,
|
||||
defaultTagName,
|
||||
}) => {
|
||||
const statusEntries = Object.entries(statuses).filter(
|
||||
([, statusList]) => statusList.length > 0,
|
||||
);
|
||||
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
|
||||
if (statusEntries.length === 0) {
|
||||
return (
|
||||
|
|
@ -33,39 +27,40 @@ export const StatusFileInfoPopup: React.FC<Props> = ({
|
|||
</div>
|
||||
|
||||
<div className="status-popup-content">
|
||||
<div className="status-tag-grid">
|
||||
{statusEntries.map(([groupName, statusList]) => {
|
||||
const isDefault = groupName === defaultTagName;
|
||||
return (
|
||||
<div key={groupName} className="status-tag-card">
|
||||
<div className="status-tag-card__header">
|
||||
<div className="status-tag-card__title">
|
||||
<span className="status-tag-card__label">
|
||||
{groupName}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span className="status-tag-card__badge">
|
||||
Default tag
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="status-tag-card__count">
|
||||
{statusList.length}
|
||||
</span>
|
||||
{statusEntries.map(([groupName, statusList]) => (
|
||||
<div key={groupName} className="status-group">
|
||||
<div className="status-group__header">
|
||||
<span className="status-group__name">
|
||||
{groupName.toLowerCase()}
|
||||
</span>
|
||||
<span className="status-group__count">
|
||||
{statusList.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="status-group__items">
|
||||
{statusList.map((status, index) => (
|
||||
<div
|
||||
key={`${groupName}-${index}`}
|
||||
className="status-item"
|
||||
>
|
||||
<StatusDisplay
|
||||
status={status}
|
||||
variant="badge"
|
||||
/>
|
||||
{status.description && (
|
||||
<div
|
||||
className="status-description"
|
||||
title={status.description}
|
||||
>
|
||||
{status.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="status-tag-card__statuses">
|
||||
{statusList.map((status, index) => (
|
||||
<StatusDisplay
|
||||
key={`${groupName}-${index}`}
|
||||
status={status}
|
||||
variant="badge"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
import React, { FC, memo } from "react";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
|
||||
import { StatusIcon } from "@/components/atoms/StatusIcon";
|
||||
|
||||
interface EditorToolbarButtonProps {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
onClick: () => void;
|
||||
unknownStatusConfig: {
|
||||
icon: string;
|
||||
lucideIcon?: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
|
||||
({ statuses, defaultTagName, onClick, unknownStatusConfig }) => {
|
||||
({ statuses, onClick, unknownStatusConfig }) => {
|
||||
const statusEntries = Object.entries(statuses);
|
||||
const allStatuses = statusEntries.flatMap(
|
||||
([_, statusList]) => statusList,
|
||||
|
|
@ -23,7 +20,7 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
|
|||
const totalStatuses = allStatuses.length;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
StatusesInfoPopup.open({ statuses, defaultTagName });
|
||||
StatusesInfoPopup.open(statuses);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
|
|
@ -43,13 +40,12 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
|
|||
onMouseLeave={handleMouseLeave}
|
||||
aria-label="Add status to note"
|
||||
>
|
||||
<StatusIcon
|
||||
icon={unknownStatusConfig.icon}
|
||||
lucideIcon={unknownStatusConfig.lucideIcon}
|
||||
size={16}
|
||||
<span
|
||||
className="editor-toolbar-button__icon"
|
||||
style={{ color: unknownStatusConfig.color }}
|
||||
/>
|
||||
>
|
||||
{unknownStatusConfig.icon}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,17 +63,16 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
|
|||
onMouseLeave={handleMouseLeave}
|
||||
aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
|
||||
>
|
||||
<StatusIcon
|
||||
icon={primaryStatus.icon}
|
||||
lucideIcon={primaryStatus.lucideIcon}
|
||||
size={16}
|
||||
<span
|
||||
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
|
||||
style={{
|
||||
color:
|
||||
primaryStatus.color ||
|
||||
"var(--interactive-accent)",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{primaryStatus.icon || "📝"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,17 +88,16 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
|
|||
aria-label={`${totalStatuses} statuses assigned. Click to change.`}
|
||||
>
|
||||
<div className="editor-toolbar-button__icon-container">
|
||||
<StatusIcon
|
||||
icon={primaryStatus.icon}
|
||||
lucideIcon={primaryStatus.lucideIcon}
|
||||
size={16}
|
||||
<span
|
||||
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
|
||||
style={{
|
||||
color:
|
||||
primaryStatus.color ||
|
||||
"var(--interactive-accent)",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{primaryStatus.icon || "📝"}
|
||||
</span>
|
||||
<span className="editor-toolbar-button__counter">
|
||||
{totalStatuses}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -12,16 +12,18 @@ export const GroupLabel: FC<GroupLabelProps> = ({
|
|||
}) => {
|
||||
if (!name) return null;
|
||||
|
||||
const className = [
|
||||
"setting-item-name",
|
||||
"group-label-container",
|
||||
isHighlighted ? "group-label-container--highlighted" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<span
|
||||
className="setting-item-name group-label-container"
|
||||
style={{
|
||||
color: isHighlighted
|
||||
? "var(--text-normal)"
|
||||
: "var(--text-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<ObsidianIcon name="folder" size={16} />
|
||||
{name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ interface BaseInputProps {
|
|||
style?: React.CSSProperties;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TextInputProps extends BaseInputProps {
|
||||
|
|
@ -28,6 +27,37 @@ interface SearchInputProps extends BaseInputProps {
|
|||
|
||||
export type InputProps = TextInputProps | ColorInputProps | SearchInputProps;
|
||||
|
||||
const getInputStyles = (variant: InputVariant): React.CSSProperties => {
|
||||
const baseStyles: React.CSSProperties = {
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "var(--background-primary)",
|
||||
color: "var(--text-normal)",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case "text":
|
||||
case "search":
|
||||
return {
|
||||
...baseStyles,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
};
|
||||
case "color":
|
||||
return {
|
||||
...baseStyles,
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: "2px",
|
||||
cursor: "pointer",
|
||||
};
|
||||
default:
|
||||
return baseStyles;
|
||||
}
|
||||
};
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
|
|
@ -39,37 +69,23 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
disabled,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputType =
|
||||
variant === "color"
|
||||
? "color"
|
||||
: variant === "search"
|
||||
? "search"
|
||||
: "text";
|
||||
const classes = [
|
||||
"note-status-input",
|
||||
`note-status-input--${variant}`,
|
||||
variant === "search" ? "search-input" : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const inputStyles = getInputStyles(variant);
|
||||
const combinedStyles = { ...inputStyles, ...style };
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={inputType}
|
||||
type={variant === "color" ? "color" : "text"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={classes}
|
||||
style={style}
|
||||
className={className}
|
||||
style={combinedStyles}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode } from "react";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import { ObsidianIcon } from "./ObsidianIcon";
|
||||
|
||||
interface SelectableListItemProps {
|
||||
|
|
@ -20,6 +20,8 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
|
|||
className = "",
|
||||
title,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -28,25 +30,60 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const composedClassName = ["selectable-list-item", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={composedClassName}
|
||||
data-selected={selected}
|
||||
data-focused={focused}
|
||||
className={`selectable-list-item ${className}`}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={title}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
padding: "8px 12px",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
borderBottom: "1px solid var(--background-modifier-border)",
|
||||
transition: "background-color 150ms ease",
|
||||
background:
|
||||
selected || isHovered || focused
|
||||
? "var(--background-modifier-hover)"
|
||||
: "",
|
||||
outline: focused
|
||||
? "2px solid var(--interactive-accent)"
|
||||
: "none",
|
||||
outlineOffset: "-2px",
|
||||
}}
|
||||
>
|
||||
{icon && <span className="selectable-list-item-icon">{icon}</span>}
|
||||
<span className="selectable-list-item-content">{children}</span>
|
||||
{icon && (
|
||||
<span
|
||||
className="selectable-list-item-icon"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
minWidth: "20px",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="selectable-list-item-content"
|
||||
style={{
|
||||
flex: "1",
|
||||
fontSize: "var(--font-ui-small)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{selected && (
|
||||
<ObsidianIcon
|
||||
name="check"
|
||||
<div
|
||||
className="selectable-list-item-check"
|
||||
/>
|
||||
style={{
|
||||
color: "var(--interactive-accent)",
|
||||
}}
|
||||
>
|
||||
<ObsidianIcon name="check" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,28 +1,16 @@
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import {
|
||||
CSSProperties,
|
||||
FC,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { FC, memo, useState } from "react";
|
||||
import { getStatusTooltip } from "@/utils/statusUtils";
|
||||
import { ObsidianIcon } from "./ObsidianIcon";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
export type StatusDisplayVariant = "chip" | "badge" | "template";
|
||||
|
||||
interface StatusDisplayProps {
|
||||
status: NoteStatus;
|
||||
variant: StatusDisplayVariant;
|
||||
badgeStyle?: "accent" | "filled" | "dot";
|
||||
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
|
||||
removable?: boolean;
|
||||
hasNameConflicts?: boolean;
|
||||
templateNameMode?: "always" | "never" | "auto";
|
||||
icon?: ReactNode;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -32,11 +20,8 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
|
|||
status,
|
||||
variant,
|
||||
removable = false,
|
||||
badgeStyle = "accent",
|
||||
badgeContentMode = "icon-text",
|
||||
hasNameConflicts = false,
|
||||
templateNameMode = "auto",
|
||||
icon,
|
||||
onRemove,
|
||||
onClick,
|
||||
}) => {
|
||||
|
|
@ -61,7 +46,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
|
|||
: status.name;
|
||||
};
|
||||
|
||||
const handleRemove = (e: MouseEvent) => {
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onRemove) {
|
||||
setIsRemoving(true);
|
||||
|
|
@ -77,102 +62,71 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
|
|||
}
|
||||
};
|
||||
|
||||
const defaultIcon = useMemo(() => {
|
||||
const baseSize = variant === "chip" ? 16 : 14;
|
||||
return (
|
||||
<StatusIcon
|
||||
icon={status.icon}
|
||||
lucideIcon={status.lucideIcon}
|
||||
size={baseSize}
|
||||
/>
|
||||
);
|
||||
}, [status.icon, status.lucideIcon, variant]);
|
||||
|
||||
const iconNode = icon ?? defaultIcon;
|
||||
|
||||
if (variant === "chip") {
|
||||
const chipClassName = [
|
||||
"note-status-chip",
|
||||
isRemoving ? "note-status-chip--removing" : "",
|
||||
onClick ? "note-status-chip--clickable" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={chipClassName}
|
||||
className="note-status-chip"
|
||||
title={getStatusTooltip(status)}
|
||||
data-clickable={!!onClick}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 8px",
|
||||
background: "var(--interactive-accent)",
|
||||
color: "var(--text-on-accent)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
fontSize: "var(--font-ui-smaller)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
transition: "all 150ms ease",
|
||||
opacity: isRemoving ? "0.5" : "1",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="note-status-chip__icon">{iconNode}</span>
|
||||
<span className="note-status-chip__text">
|
||||
<span className="note-status-chip-icon">
|
||||
{status.icon ? status.icon : "📝"}
|
||||
</span>
|
||||
<span className="note-status-chip-text">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
className="note-status-chip__remove"
|
||||
<div
|
||||
className="note-status-chip-remove"
|
||||
onClick={handleRemove}
|
||||
aria-label={`Remove status ${status.name}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255, 255, 255, 0.2)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<ObsidianIcon name="trash" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "badge") {
|
||||
const badgeStyleVars = status.color
|
||||
? ({
|
||||
"--status-accent": status.color,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
const badgeClassName = [
|
||||
"status-badge-container",
|
||||
`status-badge--${badgeStyle}`,
|
||||
onClick ? "status-badge-container--clickable" : "",
|
||||
badgeContentMode === "none" ? "status-badge--empty" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const showDot = badgeStyle === "dot";
|
||||
const showIcon =
|
||||
badgeContentMode === "icon-text" || badgeContentMode === "icon";
|
||||
const showText =
|
||||
badgeContentMode === "icon-text" || badgeContentMode === "text";
|
||||
const isEmptyBadge = badgeContentMode === "none";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={badgeClassName}
|
||||
style={badgeStyleVars}
|
||||
className="status-badge-container"
|
||||
style={{
|
||||
backgroundColor: `${status.color}15`,
|
||||
border: `1px solid ${status.color}30`,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
data-clickable={!!onClick}
|
||||
title={getStatusTooltip(status)}
|
||||
>
|
||||
{showDot && (
|
||||
<span className="status-badge-dot" aria-hidden="true" />
|
||||
)}
|
||||
<div className="status-badge-item">
|
||||
{showIcon && (
|
||||
<span className="status-badge-icon">
|
||||
{iconNode}
|
||||
</span>
|
||||
)}
|
||||
{showText && (
|
||||
<span className="status-badge-text">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
)}
|
||||
{isEmptyBadge && (
|
||||
<span
|
||||
className="status-badge-empty"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="status-badge-icon">
|
||||
{status.icon ? status.icon : "📝"}
|
||||
</span>
|
||||
<span className="status-badge-text">
|
||||
{getDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -180,24 +134,17 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
|
|||
|
||||
if (variant === "template") {
|
||||
return (
|
||||
<div
|
||||
className="template-status-chip"
|
||||
onClick={handleClick}
|
||||
data-clickable={!!onClick}
|
||||
>
|
||||
<div className="template-status-chip" onClick={handleClick}>
|
||||
<span
|
||||
className="template-status-color-dot"
|
||||
style={
|
||||
{
|
||||
"--dot-color": status.color,
|
||||
} as CSSProperties
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<span className="template-status-chip__label">
|
||||
<span className="template-status-chip__icon">
|
||||
{iconNode}
|
||||
</span>
|
||||
{getDisplayName()}
|
||||
<span>
|
||||
{status.icon ? status.icon : "📝"} {getDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import React, { CSSProperties, FC } from "react";
|
||||
import { ObsidianIcon } from "./ObsidianIcon";
|
||||
import { resolveLucideIconName } from "@/utils/iconUtils";
|
||||
|
||||
interface StatusIconProps {
|
||||
icon?: string;
|
||||
lucideIcon?: string;
|
||||
fallbackIcon?: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const StatusIcon: FC<StatusIconProps> = ({
|
||||
icon,
|
||||
lucideIcon,
|
||||
fallbackIcon = "📝",
|
||||
className = "",
|
||||
size,
|
||||
style = {},
|
||||
}) => {
|
||||
const lucideName = resolveLucideIconName(lucideIcon, icon);
|
||||
const resolvedSize = size ?? 16;
|
||||
const combinedClassName = ["status-icon", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const wrapperStyle: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: 1,
|
||||
...style,
|
||||
};
|
||||
|
||||
if (size) {
|
||||
wrapperStyle.fontSize = `${size}px`;
|
||||
}
|
||||
|
||||
const fallbackValue =
|
||||
icon && icon.trim().length ? icon : (fallbackIcon ?? "");
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} style={wrapperStyle}>
|
||||
{lucideName ? (
|
||||
<ObsidianIcon name={lucideName} size={resolvedSize} />
|
||||
) : (
|
||||
fallbackValue
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import React, { CSSProperties, FC, ReactNode, memo } from "react";
|
||||
|
||||
type IconFrameMode = "always" | "never";
|
||||
type IconColorMode = "status" | "theme";
|
||||
|
||||
export interface StatusIconPreviewProps {
|
||||
icon?: ReactNode;
|
||||
color?: string;
|
||||
count?: number;
|
||||
iconFrameMode?: IconFrameMode;
|
||||
iconColorMode?: IconColorMode;
|
||||
withWrapper?: boolean;
|
||||
compact?: boolean;
|
||||
iconClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
style?: CSSProperties;
|
||||
onMouseEnter?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onMouseLeave?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export const StatusIconPreview: FC<StatusIconPreviewProps> = memo(
|
||||
({
|
||||
icon,
|
||||
color,
|
||||
count,
|
||||
iconFrameMode = "never",
|
||||
iconColorMode = "status",
|
||||
withWrapper = true,
|
||||
compact = false,
|
||||
iconClassName = "",
|
||||
wrapperClassName = "",
|
||||
style,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}) => {
|
||||
const useStatusColor = iconColorMode === "status";
|
||||
const appliedColor = useStatusColor && color ? color.trim() : undefined;
|
||||
|
||||
const iconStyles: CSSProperties = {
|
||||
...style,
|
||||
};
|
||||
|
||||
if (appliedColor) {
|
||||
iconStyles.color = appliedColor;
|
||||
}
|
||||
|
||||
if (iconFrameMode === "always") {
|
||||
const frameColor = appliedColor || "currentColor";
|
||||
iconStyles.boxShadow = `0 0 0 1px ${frameColor}`;
|
||||
iconStyles.borderRadius = "var(--radius-s)";
|
||||
}
|
||||
|
||||
const iconContent = (
|
||||
<div
|
||||
className={`status-minimal${compact ? " status-minimal--compact" : ""}${iconClassName ? ` ${iconClassName}` : ""}`}
|
||||
style={iconStyles}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<span className="status-minimal__icon">{icon ?? "📝"}</span>
|
||||
{count && count > 1 && (
|
||||
<span
|
||||
className="status-minimal__count"
|
||||
style={
|
||||
appliedColor
|
||||
? {
|
||||
backgroundColor: appliedColor,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!withWrapper) {
|
||||
return iconContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`status-wrapper${wrapperClassName ? ` ${wrapperClassName}` : ""}`}
|
||||
>
|
||||
{iconContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -2,27 +2,16 @@ import React, { memo, useCallback } from "react";
|
|||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { SelectableListItem } from "./SelectableListItem";
|
||||
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
|
||||
import { StatusIconPreview } from "./StatusIconPreview";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
interface StatusOptionProps {
|
||||
status: NoteStatus;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
onSelect: () => void;
|
||||
iconFrameMode: "always" | "never";
|
||||
iconColorMode: "status" | "theme";
|
||||
}
|
||||
|
||||
export const StatusModalOption: React.FC<StatusOptionProps> = memo(
|
||||
({
|
||||
status,
|
||||
isSelected,
|
||||
isFocused,
|
||||
onSelect,
|
||||
iconFrameMode,
|
||||
iconColorMode,
|
||||
}) => {
|
||||
({ status, isSelected, isFocused, onSelect }) => {
|
||||
const displayName = status.templateId
|
||||
? `${status.name} (${status.templateId})`
|
||||
: status.name;
|
||||
|
|
@ -31,40 +20,14 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
|
|||
<SelectableListItem
|
||||
selected={isSelected}
|
||||
focused={isFocused}
|
||||
icon={
|
||||
<div className="note-status-option__icon-wrapper">
|
||||
<StatusIconPreview
|
||||
icon={
|
||||
<StatusIcon
|
||||
icon={status.icon}
|
||||
lucideIcon={status.lucideIcon}
|
||||
size={13}
|
||||
/>
|
||||
}
|
||||
color={status.color}
|
||||
iconFrameMode={iconFrameMode}
|
||||
iconColorMode={iconColorMode}
|
||||
withWrapper={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
icon={status.icon}
|
||||
onClick={onSelect}
|
||||
className="note-status-option"
|
||||
title={
|
||||
status.description ? getStatusTooltip(status) : undefined
|
||||
}
|
||||
>
|
||||
<div className="note-status-option__text">
|
||||
<span className="note-status-option__name">
|
||||
{displayName}
|
||||
</span>
|
||||
{status.description && (
|
||||
<span className="note-status-option__description">
|
||||
{status.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{displayName}
|
||||
</SelectableListItem>
|
||||
);
|
||||
},
|
||||
|
|
@ -75,8 +38,6 @@ export interface Props {
|
|||
availableStatuses: NoteStatus[];
|
||||
focusedIndex?: number;
|
||||
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
|
||||
iconFrameMode?: "always" | "never";
|
||||
iconColorMode?: "status" | "theme";
|
||||
}
|
||||
|
||||
export const StatusSelector: React.FC<Props> = ({
|
||||
|
|
@ -84,8 +45,6 @@ export const StatusSelector: React.FC<Props> = ({
|
|||
availableStatuses,
|
||||
focusedIndex = -1,
|
||||
onToggleStatus,
|
||||
iconFrameMode = "never",
|
||||
iconColorMode = "status",
|
||||
}) => {
|
||||
const handleSelectStatus = useCallback(
|
||||
async (status: NoteStatus) => {
|
||||
|
|
@ -96,16 +55,23 @@ export const StatusSelector: React.FC<Props> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="note-status-options" role="listbox">
|
||||
<div
|
||||
className="note-status-options"
|
||||
style={{
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "var(--radius-s)",
|
||||
background: "var(--background-primary)",
|
||||
}}
|
||||
>
|
||||
{availableStatuses.map((status, index) => (
|
||||
<StatusModalOption
|
||||
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}:${status.lucideIcon ?? ""}`}
|
||||
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
|
||||
status={status}
|
||||
isSelected={isStatusSelected(status, currentStatuses)}
|
||||
isFocused={index === focusedIndex}
|
||||
onSelect={() => handleSelectStatus(status)}
|
||||
iconFrameMode={iconFrameMode}
|
||||
iconColorMode={iconColorMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { PluginSettings } from "types/pluginSettings";
|
||||
import { PREDEFINED_TEMPLATES } from "./predefinedTemplates";
|
||||
import {
|
||||
DEFAULT_ENABLED_TEMPLATES,
|
||||
PREDEFINED_TEMPLATES,
|
||||
} from "./predefinedTemplates";
|
||||
|
||||
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
|
||||
fileExplorerIconPosition: "absolute-right",
|
||||
fileExplorerIconFrame: "never",
|
||||
fileExplorerIconColorMode: "status",
|
||||
statusColors: {
|
||||
active: "var(--text-success)",
|
||||
onHold: "var(--text-warning)",
|
||||
|
|
@ -13,56 +15,26 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
|
|||
},
|
||||
showStatusBar: true,
|
||||
autoHideStatusBar: false,
|
||||
enableStatusOverviewPopup: true,
|
||||
templates: JSON.parse(JSON.stringify(PREDEFINED_TEMPLATES)),
|
||||
templates: [...PREDEFINED_TEMPLATES],
|
||||
customStatuses: [],
|
||||
showStatusIconsInExplorer: true,
|
||||
hideUnknownStatusInExplorer: true, // Default to hide unknown status
|
||||
fileExplorerColorFileName: false,
|
||||
fileExplorerColorBlock: false,
|
||||
fileExplorerLeftBorder: false,
|
||||
fileExplorerStatusDot: false,
|
||||
fileExplorerUnderlineFileName: false,
|
||||
enableExperimentalFeatures: true,
|
||||
enableStatusDashboard: true,
|
||||
enableGroupedStatusView: true,
|
||||
enabledTemplates: PREDEFINED_TEMPLATES.map((t) => t.id),
|
||||
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
|
||||
useCustomStatusesOnly: false,
|
||||
useMultipleStatuses: true,
|
||||
singleStatusStorageMode: "list",
|
||||
statusBarShowTemplateName: "auto", // Default to show template names only when needed
|
||||
tagPrefix: "obsidian-note-status",
|
||||
strictStatuses: false, // Default to show all statuses from frontmatter
|
||||
quickStatusCommands: ["active", "completed"], // Add default quick commands
|
||||
// Unknown status customization
|
||||
unknownStatusIcon: "❓",
|
||||
unknownStatusLucideIcon: "",
|
||||
unknownStatusColor: "#8b949e",
|
||||
statusBarNoStatusText: "No status",
|
||||
statusBarShowNoStatusIcon: false,
|
||||
statusBarShowNoStatusText: true,
|
||||
statusBarBadgeStyle: "accent",
|
||||
statusBarBadgeContentMode: "icon-text",
|
||||
vaultSizeLimit: 15000, // Disable dashboard and grouped view for vaults with more notes than this limit
|
||||
// Editor toolbar button settings
|
||||
showEditorToolbarButton: true, // Default to show the toolbar button
|
||||
editorToolbarButtonPosition: "right", // Default position on the right
|
||||
editorToolbarButtonDisplay: "all-notes", // Default to show button in all notes
|
||||
applyStatusRecursivelyToSubfolders: false,
|
||||
statusFrontmatterMappings: [],
|
||||
writeMappedTagsToDefault: false,
|
||||
enableExternalStatusSync: false,
|
||||
externalStatusSyncPath: "_note-status-sync.json",
|
||||
syncGroups: [
|
||||
"templates",
|
||||
"customStatuses",
|
||||
"statusColors",
|
||||
"uiAppearance",
|
||||
"workflow",
|
||||
"storage",
|
||||
"features",
|
||||
],
|
||||
enableNonMarkdownSync: false,
|
||||
nonMarkdownSyncPath: "_note-status-data.json",
|
||||
defaultStatusForNewNotes: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,72 +1,14 @@
|
|||
/**
|
||||
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
|
||||
* To add a new predefined template, create a JSON file in the /templates/predefined directory.
|
||||
* To add a community template, create a JSON file in the /templates directory.
|
||||
*/
|
||||
import { StatusTemplate } from "types/pluginSettings";
|
||||
|
||||
/**
|
||||
* Predefined status templates
|
||||
*/
|
||||
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
||||
{
|
||||
id: "academic",
|
||||
name: "Academic research",
|
||||
description: "Status workflow for academic research and writing",
|
||||
language: "English",
|
||||
type: "Academic",
|
||||
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",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
{
|
||||
id: "colorful",
|
||||
name: "Colorful workflow",
|
||||
description:
|
||||
"A colorful set of workflow statuses with descriptive icons",
|
||||
language: "English",
|
||||
type: "General",
|
||||
authorGithub: "soler1212",
|
||||
statuses: [
|
||||
{
|
||||
name: "idea",
|
||||
|
|
@ -123,92 +65,11 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
|||
templateId: "colorful",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
{
|
||||
id: "creative-writing",
|
||||
name: "Creative Writing",
|
||||
description: "Workflow for novelists and creative writers",
|
||||
language: "English",
|
||||
type: "Writing",
|
||||
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",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
{
|
||||
id: "default-starter",
|
||||
name: "Starter Template",
|
||||
description:
|
||||
"A simplified set of essential workflow statuses to get you started.",
|
||||
language: "English",
|
||||
type: "General",
|
||||
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",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
{
|
||||
id: "minimal",
|
||||
name: "Minimal workflow",
|
||||
description: "A simplified set of essential workflow statuses",
|
||||
language: "English",
|
||||
type: "General",
|
||||
authorGithub: "soler1212",
|
||||
statuses: [
|
||||
{
|
||||
name: "todo",
|
||||
|
|
@ -235,15 +96,60 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
|||
templateId: "minimal",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
{
|
||||
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",
|
||||
language: "English",
|
||||
type: "Project Management",
|
||||
authorGithub: "soler1212",
|
||||
statuses: [
|
||||
{
|
||||
name: "planning",
|
||||
|
|
@ -294,51 +200,10 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
|
|||
templateId: "project",
|
||||
},
|
||||
],
|
||||
isPredefined: true,
|
||||
},
|
||||
];
|
||||
export const COMMUNITY_TEMPLATES: StatusTemplate[] = [
|
||||
{
|
||||
id: "research-note",
|
||||
name: "Research-note",
|
||||
description:
|
||||
"Workflow for managing first iteration of note-taking, starting second iteration, reviewing, and completing",
|
||||
authorGithub: "JuliaHusar",
|
||||
statuses: [
|
||||
{
|
||||
name: "First Pass",
|
||||
icon: "",
|
||||
lucideIcon: "",
|
||||
color: "#eb2424",
|
||||
templateId: "research-note",
|
||||
description: "Note creation and initial addition of content",
|
||||
},
|
||||
{
|
||||
name: "Second Pass",
|
||||
icon: "🕵️♀️",
|
||||
lucideIcon: "",
|
||||
color: "#f29718",
|
||||
templateId: "research-note",
|
||||
description: "Adding additional information to note",
|
||||
},
|
||||
{
|
||||
name: "Review",
|
||||
icon: "👩💻",
|
||||
lucideIcon: "",
|
||||
color: "#ede61d",
|
||||
templateId: "research-note",
|
||||
description:
|
||||
"Reviewing note and adding anything that might be missed",
|
||||
},
|
||||
{
|
||||
name: "Complete",
|
||||
icon: "✅",
|
||||
lucideIcon: "",
|
||||
color: "#41c511",
|
||||
templateId: "research-note",
|
||||
description: "Note is complete",
|
||||
},
|
||||
],
|
||||
isPredefined: false,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Default template IDs that should be enabled by default
|
||||
*/
|
||||
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
|
|||
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 { getAllFrontmatterKeys } from "./statusKeyHelpers";
|
||||
|
||||
export class CommandsService {
|
||||
private plugin: Plugin;
|
||||
|
|
@ -73,20 +70,23 @@ export class CommandsService {
|
|||
// Get the current file data
|
||||
const statusService = this.createStatusService(file);
|
||||
statusService.populateStatuses();
|
||||
const flattened = this.flattenStatuses(
|
||||
statusService.statuses,
|
||||
);
|
||||
const currentStatus = flattened[0];
|
||||
const statuses =
|
||||
statusService.statuses[
|
||||
settingsService.settings.tagPrefix
|
||||
] || [];
|
||||
const currentStatus = statuses.length
|
||||
? statuses?.[0].name
|
||||
: undefined;
|
||||
let nextIndex = 0;
|
||||
if (currentStatus) {
|
||||
const currentIndex = allStatuses.findIndex(
|
||||
(s) =>
|
||||
s.name === currentStatus.name &&
|
||||
(s.templateId || null) ===
|
||||
(currentStatus.templateId || null),
|
||||
(s) => s.name === currentStatus,
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
nextIndex = (currentIndex + 1) % allStatuses.length;
|
||||
nextIndex =
|
||||
currentIndex === -1
|
||||
? 0
|
||||
: (currentIndex + 1) % allStatuses.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,15 +213,10 @@ export class CommandsService {
|
|||
|
||||
if (!statuses || statuses.length === 0) return false;
|
||||
|
||||
const keys = getAllFrontmatterKeys();
|
||||
const queries = Array.from(
|
||||
new Set(
|
||||
statuses.flatMap((status) =>
|
||||
keys.map((key) => `[${key}:"${status}"]`),
|
||||
),
|
||||
),
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const queries = statuses.map(
|
||||
(status) => `[${tagPrefix}:"${status}"]`,
|
||||
);
|
||||
if (!queries.length) return false;
|
||||
const query = queries.join(" OR ");
|
||||
|
||||
// @ts-ignore
|
||||
|
|
@ -234,17 +229,15 @@ export class CommandsService {
|
|||
});
|
||||
this.registeredCommands.add("search-by-status");
|
||||
|
||||
// Open Status Pane command (experimental)
|
||||
if (this.shouldEnableGroupedView()) {
|
||||
this.plugin.addCommand({
|
||||
id: "open-status-pane",
|
||||
name: "Open status pane",
|
||||
callback: async () => {
|
||||
await this.openStatusPane();
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("open-status-pane");
|
||||
}
|
||||
// Open Status Pane command
|
||||
this.plugin.addCommand({
|
||||
id: "open-status-pane",
|
||||
name: "Open status pane",
|
||||
callback: async () => {
|
||||
await this.openStatusPane();
|
||||
},
|
||||
});
|
||||
this.registeredCommands.add("open-status-pane");
|
||||
}
|
||||
|
||||
private async openStatusPane(): Promise<void> {
|
||||
|
|
@ -344,38 +337,12 @@ 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 flattened = this.flattenStatuses(statusService.statuses);
|
||||
return flattened.length > 0
|
||||
? flattened.map((s) => s.name)
|
||||
: ["unknown"];
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const statuses = statusService.statuses[tagPrefix] || [];
|
||||
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -389,10 +356,6 @@ export class CommandsService {
|
|||
this.registeredCommands.clear();
|
||||
}
|
||||
|
||||
private shouldEnableGroupedView(): boolean {
|
||||
return isExperimentalFeatureEnabled("groupedStatusView");
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
// Remove all registered commands properly
|
||||
this.removeRegisteredCommands();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ class EventBus {
|
|||
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
|
||||
"active-file-change": new Map(),
|
||||
"plugin-settings-changed": new Map(),
|
||||
"status-changed": new Map(),
|
||||
"frontmatter-manually-changed": new Map(),
|
||||
"triggered-open-modal": new Map(),
|
||||
};
|
||||
|
||||
|
|
@ -32,8 +32,8 @@ class EventBus {
|
|||
...args: Parameters<EventBusEvents["plugin-settings-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "status-changed",
|
||||
...args: Parameters<EventBusEvents["status-changed"]>
|
||||
event: "frontmatter-manually-changed",
|
||||
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
|
||||
): void;
|
||||
publish(
|
||||
event: "triggered-open-modal",
|
||||
|
|
|
|||
|
|
@ -23,16 +23,14 @@ export class LazyElementObserver {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets up observers for multiple container elements
|
||||
* Sets up observers for a container element
|
||||
*/
|
||||
setupObservers(containers: Element[]): void {
|
||||
setupObservers(container: Element): void {
|
||||
this.cleanup();
|
||||
|
||||
this.initializeIntersectionObserver();
|
||||
this.initializeMutationObserver(containers);
|
||||
containers.forEach((container) =>
|
||||
this.observeExistingElements(container),
|
||||
);
|
||||
this.initializeMutationObserver(container);
|
||||
this.observeExistingElements(container);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -87,16 +85,14 @@ export class LazyElementObserver {
|
|||
/**
|
||||
* Initializes mutation observer for DOM changes
|
||||
*/
|
||||
private initializeMutationObserver(containers: Element[]): void {
|
||||
private initializeMutationObserver(container: Element): void {
|
||||
this.mutationObserver = new MutationObserver((mutations) =>
|
||||
this.handleMutations(mutations),
|
||||
);
|
||||
|
||||
containers.forEach((container) => {
|
||||
this.mutationObserver?.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
this.mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,6 @@ import {
|
|||
} from "@/types/noteStatus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import eventBus from "./eventBus";
|
||||
import statusStoreManager from "./statusStoreManager";
|
||||
import type { StatusStore } from "./statusStores/types";
|
||||
import { resolveFrontmatterKeysForStatus } from "@/utils/frontmatterMappings";
|
||||
import { getFrontmatterKeysForFile } from "./statusKeyHelpers";
|
||||
|
||||
export abstract class BaseNoteStatusService {
|
||||
static app: App;
|
||||
|
|
@ -106,44 +102,6 @@ export abstract class BaseNoteStatusService {
|
|||
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName);
|
||||
}
|
||||
|
||||
protected toScopedStatusName(
|
||||
status: StatusIdentifier | NoteStatus,
|
||||
): ScopedStatusName {
|
||||
if (typeof status === "string") {
|
||||
return BaseNoteStatusService.parseStatusIdentifier(status);
|
||||
}
|
||||
return {
|
||||
name: status.name,
|
||||
templateId: status.templateId,
|
||||
};
|
||||
}
|
||||
|
||||
protected hasExplicitMappingForStatus(
|
||||
status: StatusIdentifier | NoteStatus,
|
||||
): boolean {
|
||||
const mappings =
|
||||
settingsService.settings.statusFrontmatterMappings ?? [];
|
||||
if (!mappings.length) {
|
||||
return false;
|
||||
}
|
||||
const scoped = this.toScopedStatusName(status);
|
||||
return mappings.some((mapping) => {
|
||||
if (mapping.scope === "status") {
|
||||
return (
|
||||
mapping.statusName === scoped.name &&
|
||||
(mapping.templateId || null) === (scoped.templateId || null)
|
||||
);
|
||||
}
|
||||
if (mapping.scope === "template") {
|
||||
return (
|
||||
Boolean(scoped.templateId) &&
|
||||
mapping.templateId === scoped.templateId
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
protected getStatusMetadataKeys(): string[] {
|
||||
return [settingsService.settings.tagPrefix];
|
||||
}
|
||||
|
|
@ -161,208 +119,58 @@ export abstract class BaseNoteStatusService {
|
|||
|
||||
export class NoteStatusService extends BaseNoteStatusService {
|
||||
private file: TFile;
|
||||
private statusStore: StatusStore;
|
||||
|
||||
constructor(file: TFile) {
|
||||
super();
|
||||
this.file = file;
|
||||
this.statusStore = statusStoreManager.getStoreForFile(file);
|
||||
}
|
||||
|
||||
private isMarkdownFile(): boolean {
|
||||
return this.file.extension === "md";
|
||||
}
|
||||
|
||||
private collectIdentifiersForFile(): string[] {
|
||||
const keysToRead = getFrontmatterKeysForFile(this.file);
|
||||
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 buildStatusesArray(identifiers: string[]): NoteStatusType[] {
|
||||
if (!identifiers.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let statuses = identifiers
|
||||
.map((identifier) => this.statusNameToObject(identifier))
|
||||
.filter((status): status is NoteStatusType => status !== undefined);
|
||||
|
||||
if (statuses.length && !settingsService.settings.useMultipleStatuses) {
|
||||
statuses = statuses.slice(0, 1);
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
private shouldStoreStatusesAsArray(): boolean {
|
||||
if (settingsService.settings.useMultipleStatuses) {
|
||||
return true;
|
||||
}
|
||||
const mode = settingsService.settings.singleStatusStorageMode || "list";
|
||||
return mode === "list";
|
||||
}
|
||||
|
||||
private getStoreOptions() {
|
||||
return { storeAsArray: this.shouldStoreStatusesAsArray() };
|
||||
}
|
||||
|
||||
private resolveTargetKeys(
|
||||
frontmatterTagName: string,
|
||||
status: StatusIdentifier | NoteStatus,
|
||||
options?: { includeSourceKey?: boolean },
|
||||
): string[] {
|
||||
if (!this.isMarkdownFile()) {
|
||||
return [frontmatterTagName];
|
||||
}
|
||||
|
||||
let resolved = resolveFrontmatterKeysForStatus(
|
||||
status,
|
||||
settingsService.settings,
|
||||
{ isMarkdownFile: true },
|
||||
);
|
||||
const defaultKey = settingsService.settings.tagPrefix;
|
||||
const mappingMatched = this.hasExplicitMappingForStatus(status);
|
||||
|
||||
if (
|
||||
resolved.length === 1 &&
|
||||
resolved[0] === defaultKey &&
|
||||
frontmatterTagName !== defaultKey
|
||||
) {
|
||||
resolved = [frontmatterTagName];
|
||||
}
|
||||
|
||||
if (
|
||||
mappingMatched &&
|
||||
settingsService.settings.writeMappedTagsToDefault &&
|
||||
defaultKey &&
|
||||
this.isMarkdownFile()
|
||||
) {
|
||||
resolved = [...resolved, defaultKey];
|
||||
}
|
||||
|
||||
if (options?.includeSourceKey && frontmatterTagName) {
|
||||
resolved = [...resolved, frontmatterTagName];
|
||||
}
|
||||
|
||||
const uniqueKeys = Array.from(
|
||||
new Set(resolved.filter((key) => key && key.trim().length)),
|
||||
);
|
||||
return uniqueKeys.length ? uniqueKeys : [frontmatterTagName];
|
||||
}
|
||||
|
||||
private async runAcrossKeys(
|
||||
keys: string[],
|
||||
action: (key: string) => Promise<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 defaultKey = settingsService.settings.tagPrefix;
|
||||
this.statuses = { [defaultKey]: [] };
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
if (!this.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const identifiers = this.collectIdentifiersForFile();
|
||||
if (!identifiers.length) {
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(this.file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statuses[defaultKey] = this.buildStatusesArray(identifiers);
|
||||
}
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
let statuses: (NoteStatusType | undefined)[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
statuses = value.map((v) =>
|
||||
this.statusNameToObject(v.toString()),
|
||||
);
|
||||
} else {
|
||||
statuses = [this.statusNameToObject(value.toString())];
|
||||
}
|
||||
|
||||
public getStatusesForKey(frontmatterTagName: string): NoteStatusType[] {
|
||||
const isValidKey =
|
||||
!this.isMarkdownFile() &&
|
||||
frontmatterTagName !== settingsService.settings.tagPrefix
|
||||
? false
|
||||
: true;
|
||||
|
||||
if (!isValidKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const identifiers = this.statusStore
|
||||
.getStatuses(this.file, frontmatterTagName)
|
||||
.map((identifier) => identifier?.toString())
|
||||
.filter((identifier): identifier is string => Boolean(identifier));
|
||||
|
||||
return this.buildStatusesArray(identifiers);
|
||||
}
|
||||
|
||||
public getStatusesByAllKeys(): GroupedStatuses {
|
||||
const keys = getFrontmatterKeysForFile(this.file);
|
||||
const grouped: GroupedStatuses = {};
|
||||
let hasStatuses = false;
|
||||
|
||||
keys.forEach((key) => {
|
||||
const statuses = this.getStatusesForKey(key);
|
||||
if (!statuses.length) {
|
||||
return;
|
||||
statuses = statuses.filter((s) => s !== undefined);
|
||||
if (
|
||||
statuses.length &&
|
||||
!settingsService.settings.useMultipleStatuses
|
||||
) {
|
||||
statuses = statuses.slice(0, 1);
|
||||
}
|
||||
this.statuses[key] = statuses as NoteStatusType[];
|
||||
}
|
||||
grouped[key] = statuses;
|
||||
hasStatuses = true;
|
||||
});
|
||||
|
||||
if (!hasStatuses) {
|
||||
grouped[settingsService.settings.tagPrefix] =
|
||||
this.statuses[settingsService.settings.tagPrefix] ?? [];
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<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> {
|
||||
let removed = false;
|
||||
const targetIdentifier = status.templateId
|
||||
? BaseNoteStatusService.formatStatusIdentifier({
|
||||
templateId: status.templateId,
|
||||
|
|
@ -370,173 +178,88 @@ export class NoteStatusService extends BaseNoteStatusService {
|
|||
})
|
||||
: status.name;
|
||||
|
||||
return this.statusStore.mutateStatuses(
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
frontmatterTagName,
|
||||
(current) => {
|
||||
if (!current.length) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!noteStatusFrontmatter.length) return;
|
||||
|
||||
let i = current.findIndex(
|
||||
(statusName: string) => statusName === targetIdentifier,
|
||||
);
|
||||
|
||||
if (i === -1 && status.templateId) {
|
||||
i = current.findIndex(
|
||||
(statusName: string) => statusName === status.name,
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
// First try to find exact match (scoped or legacy)
|
||||
let i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === targetIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
if (i === -1) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
// If not found and we're looking for a scoped status, try legacy format
|
||||
if (i === -1 && status.templateId) {
|
||||
i = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) => statusName === status.name,
|
||||
);
|
||||
}
|
||||
|
||||
const nextStatuses = [...current];
|
||||
nextStatuses.splice(i, 1);
|
||||
return { hasChanged: true, nextStatuses };
|
||||
if (i !== -1) {
|
||||
noteStatusFrontmatter.splice(i, 1);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
this.getStoreOptions(),
|
||||
);
|
||||
|
||||
if (removed) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
async clearStatus(frontmatterTagName: string): Promise<boolean> {
|
||||
const keys = new Set<string>([frontmatterTagName]);
|
||||
if (this.isMarkdownFile()) {
|
||||
getFrontmatterKeysForFile(this.file).forEach((key) =>
|
||||
keys.add(key),
|
||||
);
|
||||
}
|
||||
|
||||
const cleared = await this.runAcrossKeys([...keys], (key) =>
|
||||
this.clearStatusForKey(key),
|
||||
);
|
||||
if (cleared) {
|
||||
eventBus.publish("status-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
private async clearStatusForKey(
|
||||
frontmatterTagName: string,
|
||||
): Promise<boolean> {
|
||||
return this.statusStore.mutateStatuses(
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
frontmatterTagName,
|
||||
(current) => {
|
||||
if (!current.length) {
|
||||
return { hasChanged: false };
|
||||
(frontmatter) => {
|
||||
if (frontmatterTagName in frontmatter) {
|
||||
frontmatter[frontmatterTagName] = [];
|
||||
}
|
||||
return { hasChanged: true, nextStatuses: [] };
|
||||
},
|
||||
this.getStoreOptions(),
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async overrideStatuses(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifiers: StatusIdentifier[],
|
||||
): Promise<boolean> {
|
||||
const limitedIdentifiers = settingsService.settings.useMultipleStatuses
|
||||
? statusIdentifiers
|
||||
: statusIdentifiers.slice(0, 1);
|
||||
|
||||
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<string>([
|
||||
frontmatterTagName,
|
||||
...getFrontmatterKeysForFile(this.file),
|
||||
]);
|
||||
knownKeys.forEach((key) => {
|
||||
if (!keyMap.has(key)) {
|
||||
keyMap.set(key, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const overridden = await this.runAcrossKeys(
|
||||
Array.from(keyMap.keys()),
|
||||
(key) => this.overrideStatusesForKey(key, keyMap.get(key) ?? []),
|
||||
);
|
||||
|
||||
if (overridden) {
|
||||
eventBus.publish("status-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return overridden;
|
||||
}
|
||||
|
||||
private async overrideStatusesForKey(
|
||||
frontmatterTagName: string,
|
||||
statusIdentifiers: StatusIdentifier[],
|
||||
): Promise<boolean> {
|
||||
const formattedIdentifiers = statusIdentifiers.map((id) =>
|
||||
typeof id === "string"
|
||||
? id
|
||||
: BaseNoteStatusService.formatStatusIdentifier(id),
|
||||
);
|
||||
|
||||
return this.statusStore.mutateStatuses(
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
frontmatterTagName,
|
||||
(current) => {
|
||||
if (this.statusesAreEqual(current, formattedIdentifiers)) {
|
||||
return { hasChanged: false };
|
||||
(frontmatter) => {
|
||||
if (
|
||||
frontmatterTagName in frontmatter &&
|
||||
Array.isArray(frontmatter[frontmatterTagName])
|
||||
) {
|
||||
frontmatter[frontmatterTagName].splice(0);
|
||||
frontmatter[frontmatterTagName].push(
|
||||
...formattedIdentifiers,
|
||||
);
|
||||
}
|
||||
return {
|
||||
hasChanged: true,
|
||||
nextStatuses: formattedIdentifiers,
|
||||
};
|
||||
frontmatter[frontmatterTagName] = [...formattedIdentifiers];
|
||||
},
|
||||
this.getStoreOptions(),
|
||||
);
|
||||
eventBus.publish("frontmatter-manually-changed", { file: this.file });
|
||||
return true;
|
||||
}
|
||||
|
||||
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> {
|
||||
let added = false;
|
||||
const formattedIdentifier =
|
||||
typeof statusIdentifier === "string"
|
||||
? statusIdentifier
|
||||
|
|
@ -544,48 +267,64 @@ export class NoteStatusService extends BaseNoteStatusService {
|
|||
statusIdentifier,
|
||||
);
|
||||
|
||||
return this.statusStore.mutateStatuses(
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
this.file,
|
||||
frontmatterTagName,
|
||||
(current) => {
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
(frontmatter?.[frontmatterTagName] as string[]) || [];
|
||||
if (!settingsService.settings.useMultipleStatuses) {
|
||||
const newValue = [formattedIdentifier];
|
||||
if (this.statusesAreEqual(current, newValue)) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
return { hasChanged: true, nextStatuses: newValue };
|
||||
}
|
||||
|
||||
const exactMatch = current.findIndex(
|
||||
(statusName: string) => statusName === formattedIdentifier,
|
||||
);
|
||||
|
||||
if (exactMatch !== -1) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
|
||||
let legacyIndex = -1;
|
||||
if (
|
||||
typeof statusIdentifier !== "string" &&
|
||||
statusIdentifier.templateId
|
||||
) {
|
||||
legacyIndex = current.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === statusIdentifier.name,
|
||||
);
|
||||
}
|
||||
|
||||
const nextStatuses = [...current];
|
||||
if (legacyIndex !== -1) {
|
||||
nextStatuses[legacyIndex] = formattedIdentifier;
|
||||
frontmatter[frontmatterTagName] = [formattedIdentifier];
|
||||
added = true;
|
||||
} else {
|
||||
nextStatuses.push(formattedIdentifier);
|
||||
}
|
||||
// Ensure frontmatter property exists as an array
|
||||
if (
|
||||
!frontmatter[frontmatterTagName] ||
|
||||
!Array.isArray(frontmatter[frontmatterTagName])
|
||||
) {
|
||||
frontmatter[frontmatterTagName] = [];
|
||||
}
|
||||
|
||||
return { hasChanged: true, nextStatuses };
|
||||
// Check if we already have this status (exact match)
|
||||
const exactMatch = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === formattedIdentifier,
|
||||
);
|
||||
|
||||
if (exactMatch === -1) {
|
||||
// Check if we have a legacy version of this scoped status
|
||||
let legacyIndex = -1;
|
||||
if (
|
||||
typeof statusIdentifier !== "string" &&
|
||||
statusIdentifier.templateId
|
||||
) {
|
||||
legacyIndex = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === statusIdentifier.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (legacyIndex !== -1) {
|
||||
// Replace legacy with scoped version
|
||||
noteStatusFrontmatter[legacyIndex] =
|
||||
formattedIdentifier;
|
||||
added = true;
|
||||
} else {
|
||||
// Add new status
|
||||
frontmatter[frontmatterTagName].push(
|
||||
formattedIdentifier,
|
||||
);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
this.getStoreOptions(),
|
||||
);
|
||||
if (added) {
|
||||
eventBus.publish("frontmatter-manually-changed", {
|
||||
file: this.file,
|
||||
});
|
||||
}
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -602,60 +341,109 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
|
|||
}
|
||||
|
||||
public populateStatuses(): void {
|
||||
const defaultKey = settingsService.settings.tagPrefix;
|
||||
this.statuses = { [defaultKey]: [] };
|
||||
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
|
||||
|
||||
const aggregated = new Set<string>();
|
||||
this.statuses = Object.fromEntries(
|
||||
STATUS_METADATA_KEYS.map((key) => [key, []]),
|
||||
);
|
||||
|
||||
const allStatuses = new Map<string, Set<string>>();
|
||||
|
||||
this.files.forEach((file) => {
|
||||
let store: StatusStore;
|
||||
try {
|
||||
store = statusStoreManager.getStoreForFile(file);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return;
|
||||
|
||||
const keys = getFrontmatterKeysForFile(file);
|
||||
keys.forEach((key) => {
|
||||
const identifiers = store.getStatuses(file, key);
|
||||
identifiers.forEach((identifier) => {
|
||||
const normalized = identifier?.toString();
|
||||
if (normalized && !aggregated.has(normalized)) {
|
||||
aggregated.add(normalized);
|
||||
STATUS_METADATA_KEYS.forEach((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
if (!allStatuses.has(key)) {
|
||||
allStatuses.set(key, new Set());
|
||||
}
|
||||
});
|
||||
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((name) =>
|
||||
allStatuses.get(key)!.add(name.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!aggregated.size) {
|
||||
return;
|
||||
}
|
||||
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[];
|
||||
|
||||
const statuses = Array.from(aggregated)
|
||||
.map((identifier) => this.statusNameToObject(identifier))
|
||||
.filter((status): status is NoteStatusType => status !== undefined);
|
||||
|
||||
this.statuses[defaultKey] = statuses;
|
||||
this.statuses[key] = statuses;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeStatus(
|
||||
frontmatterTagName: string,
|
||||
status: NoteStatus,
|
||||
): Promise<boolean> {
|
||||
const removalResults = await Promise.all(
|
||||
this.files.map(async (file) => {
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
return noteStatusService.removeStatus(
|
||||
frontmatterTagName,
|
||||
status,
|
||||
);
|
||||
}),
|
||||
);
|
||||
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;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
if (!noteStatusFrontmatter) return;
|
||||
|
||||
if (Array.isArray(noteStatusFrontmatter)) {
|
||||
// First try to find exact match (scoped or legacy)
|
||||
let index = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === targetIdentifier,
|
||||
);
|
||||
|
||||
// If not found and we're looking for a scoped status, try legacy format
|
||||
if (index === -1 && status.templateId) {
|
||||
index = noteStatusFrontmatter.findIndex(
|
||||
(statusName: string) =>
|
||||
statusName === status.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (index !== -1) {
|
||||
noteStatusFrontmatter.splice(index, 1);
|
||||
removed = true;
|
||||
}
|
||||
} else if (
|
||||
noteStatusFrontmatter === targetIdentifier ||
|
||||
(status.templateId &&
|
||||
noteStatusFrontmatter === status.name)
|
||||
) {
|
||||
delete frontmatter[frontmatterTagName];
|
||||
removed = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
return removed;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
removedFromAny = results.some((result) => result);
|
||||
|
||||
const removedFromAny = removalResults.some((result) => result);
|
||||
if (removedFromAny) {
|
||||
this.populateStatuses();
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return removedFromAny;
|
||||
}
|
||||
|
||||
|
|
@ -663,20 +451,55 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
|
|||
frontmatterTagName: string,
|
||||
statusIdentifier: StatusIdentifier,
|
||||
): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
this.files.map(async (file) => {
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
return noteStatusService.addStatus(
|
||||
frontmatterTagName,
|
||||
statusIdentifier,
|
||||
);
|
||||
}),
|
||||
);
|
||||
let addedToAny = false;
|
||||
const formattedIdentifier =
|
||||
typeof statusIdentifier === "string"
|
||||
? statusIdentifier
|
||||
: BaseNoteStatusService.formatStatusIdentifier(
|
||||
statusIdentifier,
|
||||
);
|
||||
|
||||
const promises = this.files.map(async (file) => {
|
||||
let added = false;
|
||||
await BaseNoteStatusService.app.fileManager.processFrontMatter(
|
||||
file,
|
||||
(frontmatter) => {
|
||||
const noteStatusFrontmatter =
|
||||
frontmatter[frontmatterTagName];
|
||||
|
||||
if (!noteStatusFrontmatter) {
|
||||
frontmatter[frontmatterTagName] = [formattedIdentifier];
|
||||
added = true;
|
||||
} else if (Array.isArray(noteStatusFrontmatter)) {
|
||||
if (
|
||||
!noteStatusFrontmatter.includes(formattedIdentifier)
|
||||
) {
|
||||
noteStatusFrontmatter.push(formattedIdentifier);
|
||||
added = true;
|
||||
}
|
||||
} else {
|
||||
if (noteStatusFrontmatter !== formattedIdentifier) {
|
||||
frontmatter[frontmatterTagName] = [
|
||||
noteStatusFrontmatter,
|
||||
formattedIdentifier,
|
||||
];
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return added;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
addedToAny = results.some((result) => result);
|
||||
|
||||
const addedToAny = results.some((result) => result);
|
||||
if (addedToAny) {
|
||||
this.populateStatuses();
|
||||
this.files.forEach((file) => {
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
});
|
||||
}
|
||||
|
||||
return addedToAny;
|
||||
}
|
||||
|
||||
|
|
@ -692,20 +515,25 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
|
|||
: status.name;
|
||||
|
||||
return this.files.filter((file) => {
|
||||
let store: StatusStore;
|
||||
try {
|
||||
store = statusStoreManager.getStoreForFile(file);
|
||||
} catch {
|
||||
return false;
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return false;
|
||||
|
||||
const value = frontmatter[frontmatterTagName];
|
||||
if (!value) return false;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Check for exact match first, then legacy format if scoped
|
||||
return (
|
||||
value.includes(targetIdentifier) ||
|
||||
(status.templateId && value.includes(status.name))
|
||||
);
|
||||
}
|
||||
|
||||
const identifiers = store.getStatuses(file, frontmatterTagName);
|
||||
if (!identifiers.length) return false;
|
||||
|
||||
return identifiers.some(
|
||||
(identifier) =>
|
||||
identifier === targetIdentifier ||
|
||||
(status.templateId && identifier === status.name),
|
||||
// Check for exact match first, then legacy format if scoped
|
||||
return (
|
||||
value === targetIdentifier ||
|
||||
(status.templateId && value === status.name)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,17 @@
|
|||
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 { PluginSettings } from "@/types/pluginSettings";
|
||||
import { Plugin } from "obsidian";
|
||||
import eventBus from "./eventBus";
|
||||
|
||||
export const SETTINGS_GROUPS: Record<SyncGroup, (keyof PluginSettings)[]> = {
|
||||
templates: ["templates", "enabledTemplates"],
|
||||
customStatuses: ["customStatuses"],
|
||||
statusColors: ["statusColors"],
|
||||
uiAppearance: [
|
||||
"fileExplorerIconPosition",
|
||||
"fileExplorerIconFrame",
|
||||
"fileExplorerIconColorMode",
|
||||
"showStatusBar",
|
||||
"autoHideStatusBar",
|
||||
"statusBarShowTemplateName",
|
||||
"statusBarBadgeStyle",
|
||||
"statusBarBadgeContentMode",
|
||||
"showStatusIconsInExplorer",
|
||||
"hideUnknownStatusInExplorer",
|
||||
"fileExplorerColorFileName",
|
||||
"fileExplorerColorBlock",
|
||||
"fileExplorerLeftBorder",
|
||||
"fileExplorerStatusDot",
|
||||
"fileExplorerUnderlineFileName",
|
||||
"unknownStatusIcon",
|
||||
"unknownStatusLucideIcon",
|
||||
"unknownStatusColor",
|
||||
"statusBarNoStatusText",
|
||||
"statusBarShowNoStatusIcon",
|
||||
"statusBarShowNoStatusText",
|
||||
"showEditorToolbarButton",
|
||||
"editorToolbarButtonPosition",
|
||||
"editorToolbarButtonDisplay",
|
||||
],
|
||||
workflow: [
|
||||
"useCustomStatusesOnly",
|
||||
"useMultipleStatuses",
|
||||
"singleStatusStorageMode",
|
||||
"strictStatuses",
|
||||
"enableStatusOverviewPopup",
|
||||
"defaultStatusForNewNotes",
|
||||
],
|
||||
storage: [
|
||||
"tagPrefix",
|
||||
"statusFrontmatterMappings",
|
||||
"writeMappedTagsToDefault",
|
||||
"applyStatusRecursivelyToSubfolders",
|
||||
],
|
||||
features: [
|
||||
"quickStatusCommands",
|
||||
"enableExperimentalFeatures",
|
||||
"enableStatusDashboard",
|
||||
"enableGroupedStatusView",
|
||||
"vaultSizeLimit",
|
||||
],
|
||||
};
|
||||
|
||||
class SettingsService {
|
||||
private plugin: Plugin;
|
||||
public settings: PluginSettings;
|
||||
private isWatcherRegistered = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
async initialize(plugin: Plugin): Promise<void> {
|
||||
this.plugin = plugin;
|
||||
const loadedData = await this.loadSettings();
|
||||
this.migrateLegacySettings(loadedData);
|
||||
|
||||
if (this.settings.enableExternalStatusSync) {
|
||||
await this.loadFromExternalFile();
|
||||
this.setupExternalFileWatcher();
|
||||
}
|
||||
await this.loadSettings();
|
||||
}
|
||||
|
||||
setValue(key: keyof PluginSettings, value: unknown) {
|
||||
|
|
@ -82,19 +21,6 @@ class SettingsService {
|
|||
// const oldValue = this.settings[key]; // TODO: Send the old value
|
||||
this.settings[key] = value;
|
||||
this.saveSettings().catch(console.error);
|
||||
|
||||
// Handle external sync
|
||||
if (this.settings.enableExternalStatusSync) {
|
||||
this.syncToExternalFile().catch(console.error);
|
||||
}
|
||||
|
||||
if (key === "enableExternalStatusSync") {
|
||||
if (value) {
|
||||
this.syncToExternalFile().catch(console.error);
|
||||
this.setupExternalFileWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
// INFO: Send the propgation event
|
||||
eventBus.publish("plugin-settings-changed", {
|
||||
key,
|
||||
|
|
@ -104,215 +30,17 @@ class SettingsService {
|
|||
}
|
||||
|
||||
private async loadSettings() {
|
||||
const loadedData = await this.plugin.loadData();
|
||||
this.settings = this.mergeSettings(DEFAULT_PLUGIN_SETTINGS, loadedData);
|
||||
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), wait, we now provide all templates in DEFAULT_PLUGIN_SETTINGS
|
||||
if (!loadedData) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges loaded settings with defaults to prevent data loss for nested objects like statusColors.
|
||||
*/
|
||||
private mergeSettings(
|
||||
defaults: PluginSettings,
|
||||
loaded: Partial<PluginSettings> | null,
|
||||
): PluginSettings {
|
||||
if (!loaded) return { ...defaults };
|
||||
|
||||
const result = { ...defaults, ...loaded };
|
||||
|
||||
// Deep merge statusColors
|
||||
if (loaded.statusColors) {
|
||||
result.statusColors = {
|
||||
...defaults.statusColors,
|
||||
...loaded.statusColors,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_PLUGIN_SETTINGS,
|
||||
await this.plugin.loadData(),
|
||||
);
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
private async saveSettings() {
|
||||
await this.plugin.saveData(this.settings);
|
||||
}
|
||||
|
||||
async syncToExternalFile(force = false) {
|
||||
if (!this.settings.enableExternalStatusSync && !force) return;
|
||||
|
||||
const path = normalizePath(this.settings.externalStatusSyncPath);
|
||||
|
||||
// Filter settings based on selected groups
|
||||
const keysToSync = new Set<keyof PluginSettings>();
|
||||
(this.settings.syncGroups || []).forEach((group) => {
|
||||
const groupKeys = SETTINGS_GROUPS[group];
|
||||
if (groupKeys) {
|
||||
groupKeys.forEach((key) => keysToSync.add(key));
|
||||
}
|
||||
});
|
||||
|
||||
const filteredSettings: Partial<PluginSettings> = {};
|
||||
keysToSync.forEach((key) => {
|
||||
(filteredSettings as Record<string, unknown>)[key] =
|
||||
this.settings[key];
|
||||
});
|
||||
|
||||
const data = {
|
||||
...filteredSettings,
|
||||
syncGroups: this.settings.syncGroups, // Always include meta-settings
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await this.plugin.app.vault.adapter.write(
|
||||
path,
|
||||
JSON.stringify(data, null, 2),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync settings to external file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromExternalFile(force = false) {
|
||||
if (!this.settings.enableExternalStatusSync && !force) return;
|
||||
const path = normalizePath(this.settings.externalStatusSyncPath);
|
||||
if (!(await this.plugin.app.vault.adapter.exists(path))) return;
|
||||
|
||||
try {
|
||||
const content = await this.plugin.app.vault.adapter.read(path);
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Determine which keys we should actually apply based on CURRENT settings
|
||||
const allowedKeys = new Set<keyof PluginSettings>();
|
||||
(this.settings.syncGroups || []).forEach((group) => {
|
||||
const groupKeys = SETTINGS_GROUPS[group];
|
||||
if (groupKeys) {
|
||||
groupKeys.forEach((key) => allowedKeys.add(key));
|
||||
}
|
||||
});
|
||||
|
||||
let hasChanged = false;
|
||||
const keys = Object.keys(data).filter(
|
||||
(k) => k !== "updatedAt" && k !== "syncGroups",
|
||||
) as Array<keyof PluginSettings>;
|
||||
|
||||
for (const key of keys) {
|
||||
if (!(key in this.settings) || !allowedKeys.has(key)) continue;
|
||||
|
||||
const newValue = data[key];
|
||||
const currentValue = this.settings[key];
|
||||
|
||||
// Basic deep equality check to avoid unnecessary updates
|
||||
if (JSON.stringify(newValue) !== JSON.stringify(currentValue)) {
|
||||
(this.settings as Record<string, unknown>)[key] = newValue;
|
||||
hasChanged = true;
|
||||
|
||||
// Notify UI and other services
|
||||
eventBus.publish("plugin-settings-changed", {
|
||||
key: key as keyof PluginSettings,
|
||||
value: newValue,
|
||||
currentSettings: this.settings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
await this.saveSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings from external file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private setupExternalFileWatcher() {
|
||||
if (this.isWatcherRegistered) return;
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on("modify", (file) => {
|
||||
if (!this.settings.enableExternalStatusSync) return;
|
||||
|
||||
if (
|
||||
file instanceof TFile &&
|
||||
file.path ===
|
||||
normalizePath(this.settings.externalStatusSyncPath)
|
||||
) {
|
||||
this.loadFromExternalFile().catch(console.error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.isWatcherRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsService();
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { TFile } from "obsidian";
|
||||
import settingsService from "./settingsService";
|
||||
import { getKnownFrontmatterKeys } from "@/utils/frontmatterMappings";
|
||||
|
||||
const ensureOrderedKeys = (keys: string[]): string[] => {
|
||||
const defaultKey = settingsService.settings.tagPrefix;
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
|
||||
[defaultKey, ...keys].forEach((key) => {
|
||||
const trimmed = key?.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
});
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
export const getAllFrontmatterKeys = (): string[] => {
|
||||
const keys = getKnownFrontmatterKeys(settingsService.settings);
|
||||
return ensureOrderedKeys(keys);
|
||||
};
|
||||
|
||||
export const getFrontmatterKeysForFile = (file: TFile): string[] => {
|
||||
if (file.extension !== "md") {
|
||||
return [settingsService.settings.tagPrefix];
|
||||
}
|
||||
|
||||
return getAllFrontmatterKeys();
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { Plugin, TFile } from "obsidian";
|
||||
import { FrontmatterStatusStore } from "./statusStores/frontmatterStatusStore";
|
||||
import { NonMarkdownStatusStore } from "./statusStores/nonMarkdownStatusStore";
|
||||
import { StatusStore } from "./statusStores/types";
|
||||
|
||||
class StatusStoreManager {
|
||||
private stores: StatusStore[] = [];
|
||||
private nonMarkdownStore: NonMarkdownStatusStore | null = null;
|
||||
|
||||
async initialize(plugin: Plugin) {
|
||||
const frontmatterStore = new FrontmatterStatusStore(plugin.app);
|
||||
this.stores = [frontmatterStore];
|
||||
|
||||
this.nonMarkdownStore = new NonMarkdownStatusStore(plugin);
|
||||
await this.nonMarkdownStore.initialize();
|
||||
this.registerStore(this.nonMarkdownStore);
|
||||
}
|
||||
|
||||
getNonMarkdownStore(): NonMarkdownStatusStore {
|
||||
if (!this.nonMarkdownStore) {
|
||||
throw new Error("NonMarkdownStatusStore is not initialized");
|
||||
}
|
||||
return this.nonMarkdownStore;
|
||||
}
|
||||
|
||||
registerStore(store: StatusStore) {
|
||||
this.stores.push(store);
|
||||
}
|
||||
|
||||
getStoreForFile(file: TFile): StatusStore {
|
||||
const store = this.stores.find((candidate) =>
|
||||
candidate.canHandle(file),
|
||||
);
|
||||
|
||||
if (!store) {
|
||||
throw new Error(
|
||||
`No status store registered for file type: ${file.extension}`,
|
||||
);
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
export default new StatusStoreManager();
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { StatusMutation, StatusStore } from "./types";
|
||||
|
||||
export class FrontmatterStatusStore implements StatusStore {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
canHandle(file: TFile): boolean {
|
||||
return file.extension === "md";
|
||||
}
|
||||
|
||||
getStatuses(file: TFile, frontmatterTagName: string): string[] {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return [];
|
||||
|
||||
return this.normalizeValue(frontmatter[frontmatterTagName]);
|
||||
}
|
||||
|
||||
async mutateStatuses(
|
||||
file: TFile,
|
||||
frontmatterTagName: string,
|
||||
mutator: (current: string[]) => StatusMutation,
|
||||
options: { storeAsArray: boolean },
|
||||
): Promise<boolean> {
|
||||
let changed = false;
|
||||
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
const currentStatuses = this.normalizeValue(
|
||||
frontmatter?.[frontmatterTagName],
|
||||
);
|
||||
const mutation = mutator([...currentStatuses]);
|
||||
|
||||
if (!mutation.hasChanged) return;
|
||||
|
||||
this.writeStatuses(
|
||||
frontmatter,
|
||||
frontmatterTagName,
|
||||
mutation.nextStatuses,
|
||||
options.storeAsArray,
|
||||
);
|
||||
changed = true;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
private normalizeValue(value: unknown): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((status) =>
|
||||
status === undefined || status === null
|
||||
? undefined
|
||||
: String(status),
|
||||
)
|
||||
.filter((status): status is string => Boolean(status));
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
private writeStatuses(
|
||||
frontmatter: Record<string, unknown>,
|
||||
frontmatterTagName: string,
|
||||
statuses: string[],
|
||||
storeAsArray: boolean,
|
||||
) {
|
||||
if (!statuses.length) {
|
||||
if (storeAsArray) {
|
||||
frontmatter[frontmatterTagName] = [];
|
||||
} else {
|
||||
delete frontmatter[frontmatterTagName];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (storeAsArray) {
|
||||
frontmatter[frontmatterTagName] = [...statuses];
|
||||
} else {
|
||||
frontmatter[frontmatterTagName] = statuses[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
import { normalizePath, Plugin, TAbstractFile, TFile } from "obsidian";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { StatusMutation, StatusStore } from "./types";
|
||||
|
||||
type FileStatusMap = Record<string, Record<string, string[]>>;
|
||||
|
||||
type PersistedData = {
|
||||
version: number;
|
||||
files: FileStatusMap;
|
||||
};
|
||||
|
||||
export class NonMarkdownStatusStore implements StatusStore {
|
||||
private data: FileStatusMap = {};
|
||||
private isWatcherRegistered = false;
|
||||
|
||||
constructor(private readonly plugin: Plugin) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.data = await this.loadFromDisk();
|
||||
this.registerVaultEvents();
|
||||
this.setupSyncWatcher();
|
||||
|
||||
// Reload if settings change
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key }) => {
|
||||
if (
|
||||
key === "enableNonMarkdownSync" ||
|
||||
key === "nonMarkdownSyncPath"
|
||||
) {
|
||||
this.loadFromDisk()
|
||||
.then((newData) => {
|
||||
this.data = newData;
|
||||
this.setupSyncWatcher();
|
||||
// Notify UI that statuses might have changed
|
||||
this.plugin.app.workspace.iterateAllLeaves(
|
||||
(leaf) => {
|
||||
const view = leaf.view as {
|
||||
requestRefresh?: () => void;
|
||||
};
|
||||
if (view.requestRefresh)
|
||||
view.requestRefresh();
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
},
|
||||
"non-markdown-status-store-sync-subscriptor",
|
||||
);
|
||||
}
|
||||
|
||||
private getDataPath(): string {
|
||||
const settings = settingsService.settings;
|
||||
if (
|
||||
settings &&
|
||||
settings.enableNonMarkdownSync &&
|
||||
settings.nonMarkdownSyncPath
|
||||
) {
|
||||
return this.getVaultPath();
|
||||
}
|
||||
|
||||
return this.getInternalPath();
|
||||
}
|
||||
|
||||
private getInternalPath(): string {
|
||||
const configDir = this.plugin.app.vault.configDir;
|
||||
const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`;
|
||||
return normalizePath(`${pluginDir}/non-markdown-statuses.json`);
|
||||
}
|
||||
|
||||
private getVaultPath(): string {
|
||||
const settings = settingsService.settings;
|
||||
return normalizePath(settings.nonMarkdownSyncPath);
|
||||
}
|
||||
|
||||
async exportToVault(): Promise<void> {
|
||||
const internalPath = this.getInternalPath();
|
||||
const vaultPath = this.getVaultPath();
|
||||
|
||||
if (!(await this.plugin.app.vault.adapter.exists(internalPath))) {
|
||||
throw new Error("No internal data found to export.");
|
||||
}
|
||||
|
||||
const raw = await this.plugin.app.vault.adapter.read(internalPath);
|
||||
await this.ensureDirectoryExists(vaultPath);
|
||||
await this.plugin.app.vault.adapter.write(vaultPath, raw);
|
||||
|
||||
// Reload if vault sync is active
|
||||
if (settingsService.settings.enableNonMarkdownSync) {
|
||||
this.data = await this.loadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
async importFromVault(): Promise<void> {
|
||||
const internalPath = this.getInternalPath();
|
||||
const vaultPath = this.getVaultPath();
|
||||
|
||||
if (!(await this.plugin.app.vault.adapter.exists(vaultPath))) {
|
||||
throw new Error("No vault sync file found to import.");
|
||||
}
|
||||
|
||||
const raw = await this.plugin.app.vault.adapter.read(vaultPath);
|
||||
await this.ensureDirectoryExists(internalPath);
|
||||
await this.plugin.app.vault.adapter.write(internalPath, raw);
|
||||
|
||||
// Reload if vault sync is inactive
|
||||
if (!settingsService.settings.enableNonMarkdownSync) {
|
||||
this.data = await this.loadFromDisk();
|
||||
}
|
||||
}
|
||||
|
||||
canHandle(file: TFile): boolean {
|
||||
return file.extension !== "md";
|
||||
}
|
||||
|
||||
getStatuses(file: TFile, frontmatterTagName: string): string[] {
|
||||
const fileStatuses = this.data[file.path];
|
||||
if (!fileStatuses) return [];
|
||||
const statuses = fileStatuses[frontmatterTagName];
|
||||
return statuses ? [...statuses] : [];
|
||||
}
|
||||
|
||||
async mutateStatuses(
|
||||
file: TFile,
|
||||
frontmatterTagName: string,
|
||||
mutator: (current: string[]) => StatusMutation,
|
||||
_options: { storeAsArray: boolean },
|
||||
): Promise<boolean> {
|
||||
const fileStatuses = this.data[file.path] ?? {};
|
||||
const current = fileStatuses[frontmatterTagName] ?? [];
|
||||
const mutation = mutator([...current]);
|
||||
|
||||
if (!mutation.hasChanged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mutation.nextStatuses.length) {
|
||||
if (!this.data[file.path]) {
|
||||
this.data[file.path] = {};
|
||||
}
|
||||
this.data[file.path][frontmatterTagName] = [
|
||||
...mutation.nextStatuses,
|
||||
];
|
||||
} else if (this.data[file.path]) {
|
||||
delete this.data[file.path][frontmatterTagName];
|
||||
if (Object.keys(this.data[file.path]).length === 0) {
|
||||
delete this.data[file.path];
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistData();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async loadFromDisk(): Promise<FileStatusMap> {
|
||||
const path = this.getDataPath();
|
||||
try {
|
||||
const exists = await this.plugin.app.vault.adapter.exists(path);
|
||||
if (!exists) return {};
|
||||
|
||||
const raw = await this.plugin.app.vault.adapter.read(path);
|
||||
const parsed = JSON.parse(raw) as PersistedData;
|
||||
if (parsed && typeof parsed === "object" && parsed.files) {
|
||||
return parsed.files;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load non-markdown statuses:", e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async persistData(): Promise<void> {
|
||||
const payload: PersistedData = {
|
||||
version: 1,
|
||||
files: this.data,
|
||||
};
|
||||
const path = this.getDataPath();
|
||||
await this.ensureDirectoryExists(path);
|
||||
await this.plugin.app.vault.adapter.write(
|
||||
path,
|
||||
JSON.stringify(payload, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists(path: string): Promise<void> {
|
||||
const dir = path.split("/").slice(0, -1).join("/");
|
||||
if (!dir) return;
|
||||
if (!(await this.plugin.app.vault.adapter.exists(dir))) {
|
||||
await this.plugin.app.vault.adapter.mkdir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private setupSyncWatcher() {
|
||||
if (this.isWatcherRegistered) return;
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on("modify", (file) => {
|
||||
const settings = settingsService.settings;
|
||||
if (!settings.enableNonMarkdownSync) return;
|
||||
|
||||
if (
|
||||
file instanceof TFile &&
|
||||
file.path === normalizePath(settings.nonMarkdownSyncPath)
|
||||
) {
|
||||
this.loadFromDisk()
|
||||
.then((newData) => {
|
||||
this.data = newData;
|
||||
// Emit events for all files that might have changed
|
||||
// This is a bit heavy, but non-markdown files are fewer
|
||||
Object.keys(this.data).forEach((filePath) => {
|
||||
const f =
|
||||
this.plugin.app.vault.getAbstractFileByPath(
|
||||
filePath,
|
||||
);
|
||||
if (f instanceof TFile) {
|
||||
eventBus.publish("status-changed", {
|
||||
file: f,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.isWatcherRegistered = true;
|
||||
}
|
||||
|
||||
private registerVaultEvents() {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on("rename", (file, oldPath) => {
|
||||
this.handleRename(file, oldPath);
|
||||
}),
|
||||
);
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on("delete", (file) => {
|
||||
this.handleDelete(file);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handleRename(file: TAbstractFile, oldPath: string) {
|
||||
if (!(file instanceof TFile)) return;
|
||||
const existing = this.data[oldPath];
|
||||
if (!existing) return;
|
||||
this.data[file.path] = existing;
|
||||
delete this.data[oldPath];
|
||||
this.persistData()
|
||||
.then(() => {
|
||||
eventBus.publish("status-changed", { file });
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
private handleDelete(file: TAbstractFile) {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (!this.data[file.path]) return;
|
||||
delete this.data[file.path];
|
||||
this.persistData()
|
||||
.then(() => {
|
||||
eventBus.publish("status-changed", { file });
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { TFile } from "obsidian";
|
||||
|
||||
export type StatusMutation =
|
||||
| { hasChanged: false }
|
||||
| { hasChanged: true; nextStatuses: string[] };
|
||||
|
||||
export type StatusMutator = (current: string[]) => StatusMutation;
|
||||
|
||||
export interface StatusStore {
|
||||
canHandle(file: TFile): boolean;
|
||||
getStatuses(file: TFile, frontmatterTagName: string): string[];
|
||||
mutateStatuses(
|
||||
file: TFile,
|
||||
frontmatterTagName: string,
|
||||
mutator: StatusMutator,
|
||||
options: { storeAsArray: boolean },
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
# Feature Guide
|
||||
|
||||
## Status editing & selection
|
||||
|
||||
- Open the picker from the status bar badge, editor toolbar button, file explorer context menu, or the `Change current note status` command. The same modal handles single files, multi-select batches, and folder runs.
|
||||
- Browse by template tabs (All, per template, Custom), search, and use full keyboard navigation (arrows/Tab + Enter/Backspace/Escape). Current statuses appear as removable chips; single-status mode keeps only one value.
|
||||
- Works on Markdown and non-Markdown; resolves every relevant frontmatter key (default tag and any mappings) before writing.
|
||||
- Clipboard helpers: `Copy current note status` and `Paste status to current note` mirror what you see in the picker.
|
||||
|
||||
## Bulk changes & context menus
|
||||
|
||||
- Right-click one or many files → `Change note status` to batch-apply statuses.
|
||||
- Right-click a folder → `Apply note status to folder`; enable recursion in settings if you want nested folders included.
|
||||
- The modal shows how many files will change before you confirm.
|
||||
|
||||
## Status surfaces
|
||||
|
||||
**Status bar**
|
||||
|
||||
- Clickable badges; hide entirely or auto-hide when empty. Keep a “No status” badge with custom text/icon/color if you prefer.
|
||||
- Template names can be hidden, auto-shown only on conflicts, or always shown.
|
||||
- Badge visuals: accent stripe, solid fill, or dot; content can be icon+text, icon only, text only, or empty accent.
|
||||
|
||||
**Editor toolbar**
|
||||
|
||||
- Optional badge in the editor header. Place it left, right-before actions, or right, and choose whether it shows for all editors or only the active one.
|
||||
- Uses the same unknown-status icon/color you configure elsewhere.
|
||||
|
||||
**File explorer**
|
||||
|
||||
- Status icons appear next to file names. Choose placement (filename left, filename right, absolute right), add a frame or keep it frameless, and pick whether icons use status colors or the theme.
|
||||
- Extra cues per option: color filename, tint the row, add a left border, append a dot, underline the filename. Unknowns can be hidden.
|
||||
- Hover an icon to open the Status Overview popup listing every frontmatter key with statuses, highlighting the default tag.
|
||||
|
||||
**Status overview popup**
|
||||
|
||||
- Lightweight popup used by explorer/status icons; lists statuses per key and labels the default tag. Respects the enable/disable toggle.
|
||||
|
||||
## Dashboards and views (experimental)
|
||||
|
||||
**Status Dashboard**
|
||||
|
||||
- Ribbon icon/command (gated by Experimental Features + Dashboard toggles and the vault size limit). Shows the active note (path, modified time, statuses per tag), vault totals, and a status distribution chart. Clicking a bar runs a search for that status.
|
||||
- Quick Actions panel: open grouped view, find unassigned notes, change/cycle/clear status, copy/paste status, search by status, toggle multi-status mode, and run Quick Status Commands.
|
||||
|
||||
**Grouped Status View**
|
||||
|
||||
- Ribbon icon/command (same gating and vault limit). Groups notes by frontmatter key, then by status (template badges shown when relevant).
|
||||
- Filters: quick search, filter by note name substring, and filter by template id. Groups are collapsible and support “load more” for large sets. Clicking a note opens it in a new leaf.
|
||||
|
||||
## Templates & custom statuses
|
||||
|
||||
- Built-in templates:
|
||||
- Colorful (idea → draft → inProgress → editing → pending → onHold → needsUpdate → completed → archived)
|
||||
- Minimal (todo → inProgress → review → done)
|
||||
- Academic (research → outline → draft → review → revision → final → published)
|
||||
- Project (planning → backlog → ready → inDevelopment → testing → review → approved → live).
|
||||
- Build your own templates (name, description, ordered statuses with emoji/Lucide + color). New templates auto-enable; you can edit/delete or reset built-ins anytime.
|
||||
- Custom statuses live outside templates; create, reorder, delete them, or run in “Custom only” mode to hide template statuses entirely.
|
||||
- Unknown status handling is configurable (icon/Lucide icon, color, and whether unknowns show in explorer/status bar).
|
||||
|
||||
## Commands & keyboard shortcuts
|
||||
|
||||
- Change current note status (opens picker).
|
||||
- Cycle through statuses (single-status mode only).
|
||||
- Clear status.
|
||||
- Copy/Paste status (clipboard).
|
||||
- Toggle multiple statuses mode.
|
||||
- Search notes by current status (opens Global Search with queries for every configured frontmatter key).
|
||||
- Open grouped status view (requires toggle).
|
||||
- Quick Status Commands: select statuses in settings to auto-register `Set status to ...` commands you can bind to hotkeys.
|
||||
|
||||
## Data & compatibility
|
||||
|
||||
- Frontmatter key prefix defaults to `obsidian-note-status`; change it per vault. Map templates/statuses to extra YAML keys, and optionally also write mapped tags to the default key.
|
||||
- Single-status mode can store the value as a list (`status: [in-progress]`) or plain string (`status: in-progress`) for compatibility with plugins that expect strings.
|
||||
- Non-Markdown files store statuses in `.obsidian/plugins/obsidian-note-status/non-markdown-statuses.json`. Rename/delete events stay in sync automatically.
|
||||
- Strict status validation removes unknown statuses when you edit a file’s statuses—use with caution if you expect ad-hoc values.
|
||||
126
docs/settings.md
|
|
@ -1,126 +0,0 @@
|
|||
## Settings Reference
|
||||
|
||||
All settings are grouped the same way you see them in the plugin. Each section lists what it controls and when to tweak it.
|
||||
|
||||
---
|
||||
|
||||
## Templates & Statuses
|
||||
|
||||
### Status templates
|
||||
|
||||
- Enable/disable built-in templates or build your own (name, description, ordered statuses with emoji/Lucide + color).
|
||||
- Auto-enables new templates; you can reset built-ins anytime.
|
||||
- Good for shared workflows: research pipelines, task boards, project phases.
|
||||
|
||||
### Use only custom statuses
|
||||
|
||||
- Hides all template statuses so only your standalone statuses appear.
|
||||
- Handy for very small personal vocabularies (e.g., `idea`, `draft`, `done`).
|
||||
|
||||
### Custom statuses
|
||||
|
||||
- Create standalone statuses, reorder, delete.
|
||||
- Useful for special-purpose tags like `needsReviewByJohn` or `instagramPostReady`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Actions
|
||||
|
||||
### Quick Status Commands
|
||||
|
||||
- Registers `Set status to ...` commands you can bind to hotkeys.
|
||||
- Best when you want fast keyboard-driven changes; shines in single-status mode.
|
||||
|
||||
---
|
||||
|
||||
## Status Bar
|
||||
|
||||
### Visibility and behavior
|
||||
|
||||
- Show status icon in status bar; optionally hide when no status is set.
|
||||
- Show status overview popup on hover for a quick per-key breakdown.
|
||||
- Show template names next to status: never / auto on conflicts (default) / always.
|
||||
|
||||
### Badge visuals
|
||||
|
||||
- Badge style: accent stripe / filled / dot.
|
||||
- Badge content: icon + text / icon only / text only / empty accent.
|
||||
|
||||
### No-status display
|
||||
|
||||
- Custom text for empty state.
|
||||
- Show icon when no status.
|
||||
- Show text when no status.
|
||||
|
||||
---
|
||||
|
||||
## Editor Toolbar
|
||||
|
||||
### Toolbar button
|
||||
|
||||
- Show/hide the status button in the editor header.
|
||||
- Position: left / right-before-actions / right.
|
||||
- Show for: all notes / active note only.
|
||||
|
||||
---
|
||||
|
||||
## File Explorer
|
||||
|
||||
### Icons and placement
|
||||
|
||||
- Show status icons in file explorer.
|
||||
- Icon position: filename left / filename right / absolute right.
|
||||
- Hide icon when status is unknown.
|
||||
|
||||
### Visual treatments (per-option)
|
||||
|
||||
- Color filename text.
|
||||
- Color entire row.
|
||||
- Left border.
|
||||
- Status dot.
|
||||
- Underline filename.
|
||||
|
||||
### Icon styling
|
||||
|
||||
- Status icon frame: never / always.
|
||||
- Status icon color: use status colors / inherit theme colors.
|
||||
|
||||
---
|
||||
|
||||
## Unknown / No-Status Appearance
|
||||
|
||||
### Fallback visuals
|
||||
|
||||
- Icon for unknown status (emoji or Lucide).
|
||||
- Color for unknown status (used across status bar/explorer fallbacks).
|
||||
|
||||
---
|
||||
|
||||
## Behavior & Storage
|
||||
|
||||
### Status model
|
||||
|
||||
- Enable multiple statuses (default) or force single-status mode.
|
||||
- Single-status format when multiple is off: list (`status: [draft]`) or string (`status: draft`).
|
||||
|
||||
### Write targets
|
||||
|
||||
- Apply status recursively to subfolders (folder context menu option).
|
||||
- Status tag prefix (frontmatter key, default `obsidian-note-status`).
|
||||
- Frontmatter mappings: map templates or statuses to additional YAML keys; optional “write mapped tags to default” to keep both in sync.
|
||||
- Strict status validation: removes unknown statuses on edit (use in controlled vocabularies).
|
||||
|
||||
### Safeguards
|
||||
|
||||
- Vault size limit: disables dashboard and grouped view beyond the threshold.
|
||||
|
||||
---
|
||||
|
||||
## Experimental Features
|
||||
|
||||
### Toggles
|
||||
|
||||
- Enable experimental features (master gate).
|
||||
- Enable status dashboard.
|
||||
- Enable grouped status view.
|
||||
- Dashboard/grouped view respect the vault size limit.
|
||||
|
|
@ -1,76 +1,15 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
/**
|
||||
* Automatically generates the predefinedTemplates.ts file by scanning the templates directory.
|
||||
*/
|
||||
const generateTemplatesIndex = () => {
|
||||
const templatesDir = "./templates";
|
||||
const outputFile = "./constants/predefinedTemplates.ts";
|
||||
|
||||
const predefinedDir = path.join(templatesDir, "predefined");
|
||||
|
||||
if (!fs.existsSync(templatesDir)) return;
|
||||
if (!fs.existsSync(predefinedDir)) {
|
||||
fs.mkdirSync(predefinedDir, { recursive: true });
|
||||
}
|
||||
|
||||
const predefinedTemplates = [];
|
||||
const communityTemplates = [];
|
||||
|
||||
// Read predefined templates
|
||||
const predefinedFiles = fs.readdirSync(predefinedDir);
|
||||
predefinedFiles.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const filePath = path.join(predefinedDir, file);
|
||||
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
content.isPredefined = true;
|
||||
predefinedTemplates.push(content);
|
||||
}
|
||||
});
|
||||
|
||||
// Read community templates
|
||||
const rootFiles = fs.readdirSync(templatesDir);
|
||||
rootFiles.forEach((file) => {
|
||||
if (file.endsWith(".json")) {
|
||||
const filePath = path.join(templatesDir, file);
|
||||
const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
content.isPredefined = false;
|
||||
communityTemplates.push(content);
|
||||
}
|
||||
});
|
||||
|
||||
const content = `/**
|
||||
* THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT MANUALLY.
|
||||
* To add a new predefined template, create a JSON file in the /templates/predefined directory.
|
||||
* To add a community template, create a JSON file in the /templates directory.
|
||||
*/
|
||||
import { StatusTemplate } from "types/pluginSettings";
|
||||
|
||||
export const PREDEFINED_TEMPLATES: StatusTemplate[] = ${JSON.stringify(predefinedTemplates, null, "\t")};
|
||||
export const COMMUNITY_TEMPLATES: StatusTemplate[] = ${JSON.stringify(communityTemplates, null, "\t")};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(outputFile, content);
|
||||
console.log(
|
||||
`\x1b[36m[Templates]\x1b[0m Automatically registered ${predefinedTemplates.length} predefined and ${communityTemplates.length} community templates.`,
|
||||
);
|
||||
};
|
||||
|
||||
// Generate templates index before build
|
||||
generateTemplatesIndex();
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
// CSS bundling function
|
||||
const buildStyles = async () => {
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 143 KiB |
BIN
images/change-multiple-statuses.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 40 KiB |
BIN
images/custom-status-change.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 118 KiB |
BIN
images/file-explorer-item.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
images/file-explorer-status-icons.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 41 KiB |
BIN
images/grouped-status-filtering.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
images/no-status-toolbar.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
images/open-view-buttons.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 24 KiB |
BIN
images/status-assignment-demo.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 45 KiB |
BIN
images/status-dashboard-overview1.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
images/status-dashboard-overview2.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
images/status-dashboard-overview3.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
|
@ -32,9 +32,7 @@ export class CommandsIntegration {
|
|||
if (
|
||||
key === "quickStatusCommands" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "templates" ||
|
||||
key === "enableExperimentalFeatures" ||
|
||||
key === "enableGroupedStatusView"
|
||||
key === "templates"
|
||||
) {
|
||||
this.commandsService.destroy();
|
||||
/// BUG: if removed a command will persist because is not removed, you need the oldStates to send it to be disabled // const oldValue = this.settings[key]; // TODO: Send the old value
|
||||
|
|
|
|||
|
|
@ -5,15 +5,12 @@ import {
|
|||
Plugin,
|
||||
TAbstractFile,
|
||||
TFile,
|
||||
TFolder,
|
||||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
MultipleNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
export class ContextMenuIntegration {
|
||||
private static instance: ContextMenuIntegration | null = null;
|
||||
|
|
@ -49,32 +46,33 @@ export class ContextMenuIntegration {
|
|||
const tFiles = files.filter(
|
||||
(f): f is TFile => f instanceof TFile,
|
||||
);
|
||||
if (!tFiles.length) {
|
||||
if (tFiles.length) {
|
||||
this.openMultipleFilesStatusesModal(tFiles);
|
||||
} else {
|
||||
new Notice(
|
||||
"Select at least one file to change its status.",
|
||||
"The selected files are not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openMultipleFilesStatusesModal(tFiles);
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("file-menu", (menu, file) => {
|
||||
if (file instanceof TFile) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Change note state")
|
||||
.setIcon("rotate-ccw")
|
||||
.onClick(async () => {
|
||||
this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Change note state")
|
||||
.setIcon("rotate-ccw") // Lucide icon
|
||||
.onClick(async () => {
|
||||
if (file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(file);
|
||||
});
|
||||
});
|
||||
} else if (file instanceof TFolder) {
|
||||
this.addFolderStatusActions(menu, file);
|
||||
}
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -86,17 +84,17 @@ export class ContextMenuIntegration {
|
|||
item.setTitle("Change note status")
|
||||
.setIcon("rotate-ccw") // Lucide icon
|
||||
.onClick(async () => {
|
||||
if (
|
||||
!view.file ||
|
||||
!(view.file instanceof TFile)
|
||||
) {
|
||||
new Notice(
|
||||
"The selected item is not a valid file.",
|
||||
);
|
||||
return;
|
||||
if (view.file) {
|
||||
if (view.file instanceof TFile) {
|
||||
this.openSingleFileStatusesModal(
|
||||
view.file,
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
"The selected file is not valid to add status, just .md files can have status in this plugin version",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.openSingleFileStatusesModal(view.file);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
|
@ -119,92 +117,6 @@ export class ContextMenuIntegration {
|
|||
});
|
||||
}
|
||||
|
||||
private addFolderStatusActions(menu: Menu, folder: TFolder) {
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
|
||||
if (!availableStatuses.length) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply note status (no statuses available)")
|
||||
.setIcon("alert-triangle")
|
||||
.setDisabled(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply note status to folder")
|
||||
.setIcon("folder-sync")
|
||||
.onClick(() => {
|
||||
this.openFolderStatusModal(folder, false);
|
||||
});
|
||||
});
|
||||
|
||||
if (settingsService.settings.applyStatusRecursivelyToSubfolders) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle("Apply note status to folder and subfolders")
|
||||
.setIcon("git-merge")
|
||||
.onClick(() => {
|
||||
this.openFolderStatusModal(folder, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private openFolderStatusModal(
|
||||
folder: TFolder,
|
||||
includeSubfolders: boolean,
|
||||
): void {
|
||||
const files = this.getFilesFromFolder(folder, includeSubfolders);
|
||||
|
||||
if (!files.length) {
|
||||
new Notice("This folder does not contain any files.");
|
||||
return;
|
||||
}
|
||||
|
||||
const warningThreshold = 50;
|
||||
if (files.length >= warningThreshold) {
|
||||
new Notice(
|
||||
`This folder contains ${files.length} files. Applying status changes may take a while.`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
|
||||
const multiStatusService = new MultipleNoteStatusService(files);
|
||||
multiStatusService.populateStatuses();
|
||||
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: multiStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
private getFilesFromFolder(
|
||||
folder: TFolder,
|
||||
includeSubfolders: boolean,
|
||||
): TFile[] {
|
||||
const files: TFile[] = [];
|
||||
const queue: TFolder[] = [folder];
|
||||
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
if (!current) continue;
|
||||
|
||||
current.children.forEach((child) => {
|
||||
if (child instanceof TFile) {
|
||||
files.push(child);
|
||||
} else if (includeSubfolders && child instanceof TFolder) {
|
||||
queue.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
if (!includeSubfolders) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,6 @@ import {
|
|||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import {
|
||||
getPrimaryStatus,
|
||||
getUnknownStatusColor,
|
||||
resolveStatusColor,
|
||||
} from "@/utils/statusColor";
|
||||
import { Plugin, TFile, View } from "obsidian";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
|
||||
|
|
@ -24,17 +19,7 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
private readonly EVENT_SUBSCRIPTION_ID =
|
||||
"file-explorer-integration-subscription1";
|
||||
private readonly FILE_EXPLORER_TYPE = "file-explorer";
|
||||
private readonly NOTEBOOK_NAVIGATOR_TYPE = "notebook-navigator";
|
||||
private readonly CONTAINER_SELECTOR = ".nav-files-container, .nn-list-pane";
|
||||
private readonly FILE_NAME_COLORIZED_ATTR = "noteStatusColorized";
|
||||
private readonly FILE_NAME_ORIGINAL_COLOR_ATTR = "noteStatusOriginalColor";
|
||||
private readonly FILE_BLOCK_CLASS = "note-status-colored-block";
|
||||
private readonly FILE_BLOCK_COLOR_VAR = "--note-status-block-color";
|
||||
private readonly FILE_BORDER_CLASS = "note-status-border-left";
|
||||
private readonly FILE_BORDER_COLOR_VAR = "--note-status-border-color";
|
||||
private readonly FILE_UNDERLINE_CLASS = "note-status-underline";
|
||||
private readonly FILE_UNDERLINE_COLOR_VAR = "--note-status-underline-color";
|
||||
private readonly STATUS_DOT_CLASS = "note-status-dot-badge";
|
||||
private readonly CONTAINER_SELECTOR = ".nav-files-container";
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
this.plugin = plugin;
|
||||
|
|
@ -48,7 +33,7 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
this.setupWorkspaceIntegration();
|
||||
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
({ file }) => {
|
||||
const element = this.findFileElement(file.path);
|
||||
if (!element) return;
|
||||
|
|
@ -69,18 +54,9 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "tagPrefix" ||
|
||||
key === "statusFrontmatterMappings" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "fileExplorerIconPosition" ||
|
||||
key === "fileExplorerIconFrame" ||
|
||||
key === "fileExplorerIconColorMode" ||
|
||||
key === "fileExplorerColorFileName" ||
|
||||
key === "fileExplorerColorBlock" ||
|
||||
key === "fileExplorerLeftBorder" ||
|
||||
key === "fileExplorerStatusDot" ||
|
||||
key === "fileExplorerUnderlineFileName" ||
|
||||
key === "unknownStatusIcon" ||
|
||||
key === "unknownStatusLucideIcon" ||
|
||||
key === "unknownStatusColor" ||
|
||||
key === "templates"
|
||||
) {
|
||||
|
|
@ -95,13 +71,18 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
private getFileNoteStatusService(
|
||||
dataPath: string,
|
||||
): NoteStatusService | null {
|
||||
const abstractFile =
|
||||
this.plugin.app.vault.getAbstractFileByPath(dataPath);
|
||||
if (!(abstractFile instanceof TFile)) {
|
||||
if (!dataPath.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(abstractFile);
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(
|
||||
dataPath,
|
||||
) as TFile;
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
return noteStatusService;
|
||||
}
|
||||
|
|
@ -115,62 +96,34 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
}
|
||||
if (dataPath) {
|
||||
const textEl = element.querySelector(
|
||||
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content, .nn-file-name",
|
||||
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content",
|
||||
);
|
||||
if (!textEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = this.getFileNoteStatusService(dataPath);
|
||||
const statuses = noteStatusService?.getStatusesByAllKeys() ?? {};
|
||||
const primaryStatus = getPrimaryStatus(statuses);
|
||||
const hasStatus = Boolean(primaryStatus);
|
||||
const fallbackColor = getUnknownStatusColor();
|
||||
const statusColor = primaryStatus
|
||||
? resolveStatusColor(primaryStatus, fallbackColor)
|
||||
: undefined;
|
||||
const textElement = textEl as HTMLElement;
|
||||
const navItem = this.getNavItemElement(textElement);
|
||||
|
||||
this.applyFileNameColor(textElement, statusColor, hasStatus);
|
||||
this.applyFileNameUnderline(textElement, statusColor, hasStatus);
|
||||
this.applyStatusDot(textElement, statusColor, hasStatus);
|
||||
this.applyFileBlockColor(navItem, statusColor, hasStatus);
|
||||
this.applyLeftBorder(navItem, statusColor, hasStatus);
|
||||
|
||||
// Only render icons for markdown files
|
||||
if (noteStatusService) {
|
||||
this.render(
|
||||
textEl,
|
||||
statuses,
|
||||
settingsService.settings.tagPrefix,
|
||||
);
|
||||
this.render(textEl, noteStatusService.statuses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getUnknownStatusConfig() {
|
||||
const settings = settingsService.settings;
|
||||
return {
|
||||
icon: settingsService.settings.unknownStatusIcon || "❓",
|
||||
lucideIcon: settingsService.settings.unknownStatusLucideIcon || "",
|
||||
color: getUnknownStatusColor(),
|
||||
icon: settings.unknownStatusIcon || "❓",
|
||||
color: settings.unknownStatusColor || "#8b949e",
|
||||
};
|
||||
}
|
||||
|
||||
render(
|
||||
element: Element,
|
||||
statuses: GroupedStatuses,
|
||||
defaultTagName: string,
|
||||
): void {
|
||||
// Remove existing icon (it may be a sibling or a child from older renders)
|
||||
const existingSiblingIcon = element.parentElement?.querySelector(
|
||||
`.${this.ICON_CLASS}`,
|
||||
);
|
||||
if (existingSiblingIcon) {
|
||||
existingSiblingIcon.remove();
|
||||
}
|
||||
const existingChildIcon = element.querySelector(`.${this.ICON_CLASS}`);
|
||||
if (existingChildIcon) {
|
||||
existingChildIcon.remove();
|
||||
render(element: Element, statuses: GroupedStatuses): void {
|
||||
// Remove existing icon
|
||||
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
|
||||
if (existingIcon) {
|
||||
existingIcon.remove();
|
||||
}
|
||||
|
||||
if (!settingsService.settings.showStatusIconsInExplorer) {
|
||||
|
|
@ -185,26 +138,16 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
positionClassName = "custom-icon__absolute-right";
|
||||
}
|
||||
const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] });
|
||||
icon.setAttribute("contenteditable", "false");
|
||||
|
||||
const root = createRoot(icon);
|
||||
root.render(
|
||||
<FileExplorerIcon
|
||||
statuses={statuses}
|
||||
defaultTagName={defaultTagName}
|
||||
onMouseEnter={this.openModalInfo}
|
||||
onMouseEnter={(s) => this.openModalInfo(s)}
|
||||
onMouseLeave={this.closeModalInfo}
|
||||
hideUnknownStatus={
|
||||
settingsService.settings.hideUnknownStatusInExplorer
|
||||
}
|
||||
unknownStatusConfig={this.getUnknownStatusConfig()}
|
||||
iconFrameMode={
|
||||
settingsService.settings.fileExplorerIconFrame || "never"
|
||||
}
|
||||
iconColorMode={
|
||||
settingsService.settings.fileExplorerIconColorMode ||
|
||||
"status"
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -212,36 +155,33 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
settingsService.settings.fileExplorerIconPosition ===
|
||||
"file-name-right"
|
||||
) {
|
||||
element.insertAdjacentElement("afterend", icon);
|
||||
element.append(icon);
|
||||
} else {
|
||||
element.insertAdjacentElement("beforebegin", icon);
|
||||
element.prepend(icon);
|
||||
}
|
||||
}
|
||||
|
||||
private openModalInfo = ({
|
||||
statuses,
|
||||
defaultTagName,
|
||||
}: {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
}) => {
|
||||
private openModalInfo(statuses: GroupedStatuses) {
|
||||
if (!this.plugin) {
|
||||
return;
|
||||
}
|
||||
StatusesInfoPopup.open({ statuses, defaultTagName });
|
||||
};
|
||||
private closeModalInfo = () => {
|
||||
StatusesInfoPopup.open(statuses);
|
||||
}
|
||||
private closeModalInfo() {
|
||||
StatusesInfoPopup.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup integration and unsubscribe from events
|
||||
*/
|
||||
destroy(): void {
|
||||
this.observerService.cleanup();
|
||||
eventBus.unsubscribe("status-changed", this.EVENT_SUBSCRIPTION_ID);
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
this.EVENT_SUBSCRIPTION_ID,
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"frontmatter-manually-changed",
|
||||
"fileExplorerIntegrationSubscription2",
|
||||
);
|
||||
}
|
||||
|
|
@ -268,20 +208,15 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
}
|
||||
|
||||
/**
|
||||
* Finds file element in the file explorer views
|
||||
* Finds file element in the file explorer
|
||||
*/
|
||||
private findFileElement(filePath: string): HTMLElement | null {
|
||||
const views = this.getFileExplorerViews();
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
for (const view of views) {
|
||||
const element = view.containerEl.querySelector(
|
||||
`[data-path="${filePath}"]`,
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (element) return element;
|
||||
}
|
||||
|
||||
return null;
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
`[data-path="${filePath}"]`,
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -302,219 +237,34 @@ export class FileExplorerIntegration implements IElementProcessor {
|
|||
}
|
||||
|
||||
/**
|
||||
* Patches the file explorers with observers
|
||||
* Patches the file explorer with observers
|
||||
*/
|
||||
private patchFileExplorer(): void {
|
||||
const containers = this.getFileExplorerContainers();
|
||||
if (containers.length === 0) return;
|
||||
const container = this.getFileExplorerContainer();
|
||||
if (!container) return;
|
||||
|
||||
this.observerService.setupObservers(containers);
|
||||
this.observerService.setupObservers(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer views
|
||||
* Gets the file explorer view
|
||||
*/
|
||||
private getFileExplorerViews(): View[] {
|
||||
const leaves = [
|
||||
...this.plugin.app.workspace.getLeavesOfType(
|
||||
this.FILE_EXPLORER_TYPE,
|
||||
),
|
||||
...this.plugin.app.workspace.getLeavesOfType(
|
||||
this.NOTEBOOK_NAVIGATOR_TYPE,
|
||||
),
|
||||
];
|
||||
return leaves.map((leaf) => leaf.view);
|
||||
private getFileExplorerView(): View | null {
|
||||
const leaves = this.plugin.app.workspace.getLeavesOfType(
|
||||
this.FILE_EXPLORER_TYPE,
|
||||
);
|
||||
return leaves[0]?.view || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file explorer container elements
|
||||
* Gets the file explorer container element
|
||||
*/
|
||||
private getFileExplorerContainers(): Element[] {
|
||||
const views = this.getFileExplorerViews();
|
||||
private getFileExplorerContainer(): Element | null {
|
||||
const fileExplorerView = this.getFileExplorerView();
|
||||
if (!fileExplorerView) return null;
|
||||
|
||||
return views
|
||||
.map((view) =>
|
||||
view.containerEl.querySelector(this.CONTAINER_SELECTOR),
|
||||
)
|
||||
.filter(Boolean) as Element[];
|
||||
}
|
||||
|
||||
private applyFileNameColor(
|
||||
element?: HTMLElement | null,
|
||||
color?: string,
|
||||
hasStatus?: boolean,
|
||||
): void {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!settingsService.settings.fileExplorerColorFileName ||
|
||||
!hasStatus ||
|
||||
!color
|
||||
) {
|
||||
this.clearFileNameColor(element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR]) {
|
||||
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] =
|
||||
element.style.color || "";
|
||||
}
|
||||
|
||||
element.dataset[this.FILE_NAME_COLORIZED_ATTR] = "true";
|
||||
element.style.color = color;
|
||||
}
|
||||
|
||||
private applyFileBlockColor(
|
||||
navItem?: HTMLElement | null,
|
||||
color?: string,
|
||||
hasStatus?: boolean,
|
||||
): void {
|
||||
if (!navItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!settingsService.settings.fileExplorerColorBlock ||
|
||||
!hasStatus ||
|
||||
!color
|
||||
) {
|
||||
this.clearFileBlockColor(navItem);
|
||||
return;
|
||||
}
|
||||
|
||||
navItem.classList.add(this.FILE_BLOCK_CLASS);
|
||||
navItem.style.setProperty(this.FILE_BLOCK_COLOR_VAR, color);
|
||||
}
|
||||
|
||||
private applyLeftBorder(
|
||||
navItem?: HTMLElement | null,
|
||||
color?: string,
|
||||
hasStatus?: boolean,
|
||||
): void {
|
||||
if (!navItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!settingsService.settings.fileExplorerLeftBorder ||
|
||||
!hasStatus ||
|
||||
!color
|
||||
) {
|
||||
this.clearFileLeftBorder(navItem);
|
||||
return;
|
||||
}
|
||||
|
||||
navItem.classList.add(this.FILE_BORDER_CLASS);
|
||||
navItem.style.setProperty(this.FILE_BORDER_COLOR_VAR, color);
|
||||
}
|
||||
|
||||
private applyFileNameUnderline(
|
||||
element?: HTMLElement | null,
|
||||
color?: string,
|
||||
hasStatus?: boolean,
|
||||
): void {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!settingsService.settings.fileExplorerUnderlineFileName ||
|
||||
!hasStatus ||
|
||||
!color
|
||||
) {
|
||||
this.clearFileNameUnderline(element);
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add(this.FILE_UNDERLINE_CLASS);
|
||||
element.style.setProperty(this.FILE_UNDERLINE_COLOR_VAR, color);
|
||||
}
|
||||
|
||||
private applyStatusDot(
|
||||
element?: HTMLElement | null,
|
||||
color?: string,
|
||||
hasStatus?: boolean,
|
||||
): void {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDot = element.querySelector(
|
||||
`.${this.STATUS_DOT_CLASS}`,
|
||||
) as HTMLElement | null;
|
||||
if (
|
||||
!settingsService.settings.fileExplorerStatusDot ||
|
||||
!hasStatus ||
|
||||
!color
|
||||
) {
|
||||
if (existingDot) {
|
||||
existingDot.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dot =
|
||||
existingDot ||
|
||||
createSpan({
|
||||
cls: this.STATUS_DOT_CLASS,
|
||||
});
|
||||
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
dot.style.backgroundColor = color;
|
||||
|
||||
if (!existingDot) {
|
||||
element.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
private getNavItemElement(
|
||||
element?: HTMLElement | null,
|
||||
): HTMLElement | null {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
return element.closest(
|
||||
".nav-file, .nav-folder, .nn-file",
|
||||
) as HTMLElement | null;
|
||||
}
|
||||
|
||||
private clearFileNameColor(element: HTMLElement): void {
|
||||
if (!element.dataset[this.FILE_NAME_COLORIZED_ATTR]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousColor =
|
||||
element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR] || "";
|
||||
if (previousColor) {
|
||||
element.style.color = previousColor;
|
||||
} else {
|
||||
element.style.removeProperty("color");
|
||||
}
|
||||
|
||||
delete element.dataset[this.FILE_NAME_ORIGINAL_COLOR_ATTR];
|
||||
delete element.dataset[this.FILE_NAME_COLORIZED_ATTR];
|
||||
}
|
||||
|
||||
private clearFileBlockColor(navItem?: HTMLElement | null): void {
|
||||
if (!navItem) {
|
||||
return;
|
||||
}
|
||||
navItem.classList.remove(this.FILE_BLOCK_CLASS);
|
||||
navItem.style.removeProperty(this.FILE_BLOCK_COLOR_VAR);
|
||||
}
|
||||
|
||||
private clearFileLeftBorder(navItem?: HTMLElement | null): void {
|
||||
if (!navItem) {
|
||||
return;
|
||||
}
|
||||
navItem.classList.remove(this.FILE_BORDER_CLASS);
|
||||
navItem.style.removeProperty(this.FILE_BORDER_COLOR_VAR);
|
||||
}
|
||||
|
||||
private clearFileNameUnderline(element: HTMLElement): void {
|
||||
element.classList.remove(this.FILE_UNDERLINE_CLASS);
|
||||
element.style.removeProperty(this.FILE_UNDERLINE_COLOR_VAR);
|
||||
return fileExplorerView.containerEl.querySelector(
|
||||
this.CONTAINER_SELECTOR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,14 +11,11 @@ import {
|
|||
BaseNoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export class StatusModalIntegration extends Modal {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusModalIntegration | null = null;
|
||||
private selectorService: SelectorService;
|
||||
private activeStatus?: NoteStatus;
|
||||
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
|
|
@ -27,7 +24,6 @@ export class StatusModalIntegration extends Modal {
|
|||
static open(
|
||||
app: App,
|
||||
noteStatusService: NoteStatusService | MultipleNoteStatusService,
|
||||
activeStatus?: NoteStatus,
|
||||
) {
|
||||
if (StatusModalIntegration.instance) {
|
||||
throw new Error("Status Modal is already open");
|
||||
|
|
@ -38,7 +34,7 @@ export class StatusModalIntegration extends Modal {
|
|||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
// TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations
|
||||
// FIXME: Line 27
|
||||
|
|
@ -48,7 +44,6 @@ export class StatusModalIntegration extends Modal {
|
|||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
|
||||
StatusModalIntegration.instance.activeStatus = activeStatus;
|
||||
StatusModalIntegration.instance.open();
|
||||
}
|
||||
|
||||
|
|
@ -121,15 +116,6 @@ export class StatusModalIntegration extends Modal {
|
|||
this.selectorService.noteStatusService.statuses ?? {}
|
||||
}
|
||||
filesQuantity={filesQuantity}
|
||||
templates={settingsService.settings.templates}
|
||||
iconFrameMode={
|
||||
settingsService.settings.fileExplorerIconFrame || "never"
|
||||
}
|
||||
iconColorMode={
|
||||
settingsService.settings.fileExplorerIconColorMode ||
|
||||
"status"
|
||||
}
|
||||
activeStatus={this.activeStatus}
|
||||
onRemoveStatus={this.onRemoveStatus}
|
||||
onSelectStatus={this.onSelectStatus}
|
||||
/>,
|
||||
|
|
@ -146,7 +132,7 @@ export class StatusModalIntegration extends Modal {
|
|||
StatusModalIntegration.instance = null;
|
||||
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
"statusModalIntegrationSubscription1",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,20 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import { GroupedStatuses } from "@/types/noteStatus";
|
||||
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
|
||||
import settingsService from "@/core/settingsService";
|
||||
|
||||
export class StatusesInfoPopup {
|
||||
private root: Root | null = null;
|
||||
private static instance: StatusesInfoPopup | null = null;
|
||||
private element: HTMLElement | null = null;
|
||||
private statuses: GroupedStatuses;
|
||||
private defaultTagName: string;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static open({
|
||||
statuses,
|
||||
defaultTagName,
|
||||
}: {
|
||||
statuses: GroupedStatuses;
|
||||
defaultTagName: string;
|
||||
}) {
|
||||
// Always ensure previous instance is cleaned up before showing or when disabled
|
||||
static open(statuses: GroupedStatuses) {
|
||||
StatusesInfoPopup.close();
|
||||
|
||||
const isEnabled =
|
||||
settingsService.settings?.enableStatusOverviewPopup ?? true;
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
StatusesInfoPopup.instance = new StatusesInfoPopup();
|
||||
StatusesInfoPopup.instance.statuses = statuses;
|
||||
StatusesInfoPopup.instance.defaultTagName = defaultTagName;
|
||||
StatusesInfoPopup.instance.show();
|
||||
}
|
||||
|
||||
|
|
@ -46,12 +31,7 @@ export class StatusesInfoPopup {
|
|||
document.body.appendChild(this.element);
|
||||
|
||||
this.root = createRoot(this.element);
|
||||
this.root.render(
|
||||
<StatusFileInfoPopup
|
||||
statuses={this.statuses}
|
||||
defaultTagName={this.defaultTagName}
|
||||
/>,
|
||||
);
|
||||
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import eventBus from "core/eventBus";
|
||||
import { Plugin } from "obsidian";
|
||||
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton";
|
||||
import { StatusBar } from "@/components/StatusBar/StatusBar";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
|
||||
export class StatusBarIntegration {
|
||||
private static instance: StatusBarIntegration | null = null;
|
||||
private root: Root | null = null;
|
||||
private plugin: Plugin;
|
||||
private statusBarContainer: HTMLElement | null = null;
|
||||
private statusBarContainer: HTMLElement;
|
||||
private noteStatusService: NoteStatusService | null = null;
|
||||
private currentLeaf: WorkspaceLeaf | null = null;
|
||||
|
||||
constructor(plugin: Plugin) {
|
||||
if (StatusBarIntegration.instance) {
|
||||
|
|
@ -22,10 +23,16 @@ export class StatusBarIntegration {
|
|||
}
|
||||
|
||||
async integrate() {
|
||||
this.statusBarContainer = this.plugin.addStatusBarItem();
|
||||
this.statusBarContainer.classList.add("mod-clickable");
|
||||
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
() => {
|
||||
this.handleActiveFileChange();
|
||||
({ leaf }) => {
|
||||
if (this.isValidMarkdownLeaf(leaf)) {
|
||||
this.currentLeaf = leaf;
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
}
|
||||
},
|
||||
"statusBarIntegrationSubscription1",
|
||||
);
|
||||
|
|
@ -39,12 +46,6 @@ export class StatusBarIntegration {
|
|||
if (key === "statusBarShowTemplateName") {
|
||||
this.render(); // INFO: Force a render to set disabled or enabled
|
||||
}
|
||||
if (
|
||||
key === "statusBarBadgeStyle" ||
|
||||
key === "statusBarBadgeContentMode"
|
||||
) {
|
||||
this.render();
|
||||
}
|
||||
if (key === "enabledTemplates" || key === "templates") {
|
||||
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
|
||||
}
|
||||
|
|
@ -57,9 +58,6 @@ 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
|
||||
}
|
||||
|
|
@ -68,7 +66,6 @@ export class StatusBarIntegration {
|
|||
}
|
||||
if (
|
||||
key === "unknownStatusIcon" ||
|
||||
key === "unknownStatusLucideIcon" ||
|
||||
key === "unknownStatusColor" ||
|
||||
key === "statusBarNoStatusText" ||
|
||||
key === "statusBarShowNoStatusIcon" ||
|
||||
|
|
@ -81,7 +78,7 @@ export class StatusBarIntegration {
|
|||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
() => {
|
||||
this.handleActiveFileChange().catch(console.error);
|
||||
},
|
||||
|
|
@ -89,20 +86,30 @@ export class StatusBarIntegration {
|
|||
);
|
||||
}
|
||||
|
||||
private async handleActiveFileChange(): Promise<void> {
|
||||
const file = this.plugin.app.workspace.getActiveFile();
|
||||
if (!file) {
|
||||
this.noteStatusService = null;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.noteStatusService = new NoteStatusService(file);
|
||||
this.noteStatusService.populateStatuses();
|
||||
private async handleActiveFileChange() {
|
||||
this.extractStatusesFromLeaf(this.currentLeaf);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private openStatusModal(activeStatus?: NoteStatus) {
|
||||
private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) {
|
||||
if (!this.isValidMarkdownLeaf(leaf)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const markdownView = leaf!.view as MarkdownView;
|
||||
if (!markdownView.file) {
|
||||
return {};
|
||||
}
|
||||
|
||||
this.noteStatusService = new NoteStatusService(markdownView.file);
|
||||
this.noteStatusService.populateStatuses();
|
||||
}
|
||||
|
||||
private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean {
|
||||
return leaf !== null && leaf.view.getViewType() === "markdown";
|
||||
}
|
||||
|
||||
private openStatusModal() {
|
||||
if (!this.noteStatusService) {
|
||||
throw new Error(
|
||||
"open status modal failed bcse there is no noteStatusService available",
|
||||
|
|
@ -110,7 +117,6 @@ export class StatusBarIntegration {
|
|||
}
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: this.noteStatusService,
|
||||
activeStatus,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -121,55 +127,38 @@ export class StatusBarIntegration {
|
|||
showIcon: settings.statusBarShowNoStatusIcon || false,
|
||||
showText: settings.statusBarShowNoStatusText ?? true,
|
||||
icon: settings.unknownStatusIcon || "❓",
|
||||
lucideIcon: settings.unknownStatusLucideIcon || "",
|
||||
color: settings.unknownStatusColor || "#8b949e",
|
||||
};
|
||||
}
|
||||
|
||||
private ensureStatusBarItem() {
|
||||
if (this.statusBarContainer) {
|
||||
return;
|
||||
}
|
||||
this.statusBarContainer = this.plugin.addStatusBarItem();
|
||||
this.statusBarContainer.classList.add("mod-clickable");
|
||||
this.statusBarContainer.style.padding = "unset";
|
||||
this.root = createRoot(this.statusBarContainer);
|
||||
}
|
||||
|
||||
private destroyStatusBarItem() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
if (this.statusBarContainer) {
|
||||
this.statusBarContainer.remove();
|
||||
this.statusBarContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!settingsService.settings.showStatusBar) {
|
||||
this.destroyStatusBarItem();
|
||||
return;
|
||||
if (!this.root) {
|
||||
this.root = createRoot(this.statusBarContainer);
|
||||
this.statusBarContainer.style.padding = "unset";
|
||||
}
|
||||
if (!settingsService.settings.showStatusBar) {
|
||||
this.root.render(
|
||||
<StatusBarEnableButton
|
||||
onEnableClick={() =>
|
||||
settingsService.setValue("showStatusBar", true)
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
this.root.render(
|
||||
<StatusBar
|
||||
statuses={this.noteStatusService?.statuses || {}}
|
||||
hideIfNotStatuses={
|
||||
settingsService.settings.autoHideStatusBar
|
||||
}
|
||||
templateNameMode={
|
||||
settingsService.settings.statusBarShowTemplateName
|
||||
}
|
||||
onStatusClick={() => this.openStatusModal()}
|
||||
noStatusConfig={this.getNoStatusConfig()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
this.ensureStatusBarItem();
|
||||
|
||||
this.root!.render(
|
||||
<StatusBar
|
||||
statuses={this.noteStatusService?.statuses || {}}
|
||||
hideIfNotStatuses={settingsService.settings.autoHideStatusBar}
|
||||
templateNameMode={
|
||||
settingsService.settings.statusBarShowTemplateName
|
||||
}
|
||||
badgeStyle={settingsService.settings.statusBarBadgeStyle}
|
||||
badgeContentMode={
|
||||
settingsService.settings.statusBarBadgeContentMode
|
||||
}
|
||||
onStatusClick={(status) => this.openStatusModal(status)}
|
||||
noStatusConfig={this.getNoStatusConfig()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
|
@ -182,11 +171,15 @@ export class StatusBarIntegration {
|
|||
"statusBarIntegrationSubscription2",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"plugin-settings-changed",
|
||||
"statusBarIntegrationSubscription3",
|
||||
);
|
||||
|
||||
this.destroyStatusBarItem();
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
this.root = null;
|
||||
}
|
||||
this.currentLeaf = null;
|
||||
this.noteStatusService = null;
|
||||
StatusBarIntegration.instance = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createRoot, Root } from "react-dom/client";
|
||||
import eventBus from "core/eventBus";
|
||||
import { MarkdownView, Plugin, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { EditorToolbarButton } from "@/components/Toolbar/EditorToolbarButton";
|
||||
import { NoteStatusService } from "@/core/noteStatusService";
|
||||
|
|
@ -43,11 +43,6 @@ export class EditorToolbarIntegration {
|
|||
this.handleLayoutChange();
|
||||
}),
|
||||
);
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.workspace.on("file-open", (file) => {
|
||||
this.handleFileOpen(file);
|
||||
}),
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"active-file-change",
|
||||
|
|
@ -71,9 +66,6 @@ export class EditorToolbarIntegration {
|
|||
if (key === "tagPrefix") {
|
||||
this.refreshAllButtons();
|
||||
}
|
||||
if (key === "statusFrontmatterMappings") {
|
||||
this.refreshAllButtons();
|
||||
}
|
||||
if (key === "useCustomStatusesOnly") {
|
||||
this.refreshAllButtons();
|
||||
}
|
||||
|
|
@ -82,7 +74,6 @@ export class EditorToolbarIntegration {
|
|||
}
|
||||
if (
|
||||
key === "unknownStatusIcon" ||
|
||||
key === "unknownStatusLucideIcon" ||
|
||||
key === "unknownStatusColor"
|
||||
) {
|
||||
this.renderAllButtons();
|
||||
|
|
@ -101,7 +92,7 @@ export class EditorToolbarIntegration {
|
|||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
({ file }) => {
|
||||
// Find the button for the specific file and refresh only that one
|
||||
for (const [leaf, leafButton] of this.leafButtons.entries()) {
|
||||
|
|
@ -129,7 +120,6 @@ export class EditorToolbarIntegration {
|
|||
|
||||
private handleActiveLeafChange() {
|
||||
this.syncButtons();
|
||||
this.refreshAllButtons();
|
||||
}
|
||||
|
||||
private handleDisplayModeChange() {
|
||||
|
|
@ -140,7 +130,7 @@ export class EditorToolbarIntegration {
|
|||
const targetLeaves = new Set(this.getTargetLeaves());
|
||||
|
||||
// Remove buttons that shouldn't exist
|
||||
for (const leaf of Array.from(this.leafButtons.keys())) {
|
||||
for (const leaf of this.leafButtons.keys()) {
|
||||
if (!targetLeaves.has(leaf)) {
|
||||
this.removeButtonForLeaf(leaf);
|
||||
}
|
||||
|
|
@ -158,36 +148,6 @@ export class EditorToolbarIntegration {
|
|||
this.syncButtons();
|
||||
}
|
||||
|
||||
private handleFileOpen(file: TFile | null) {
|
||||
if (!file) {
|
||||
this.refreshAllButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedAny = false;
|
||||
for (const [leaf, leafButton] of this.leafButtons.entries()) {
|
||||
const markdownView = leaf.view as MarkdownView;
|
||||
if (markdownView.file?.path !== file.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
leafButton.noteStatusService =
|
||||
this.buildNoteStatusServiceForFile(file);
|
||||
this.renderButtonForLeaf(leafButton);
|
||||
updatedAny = true;
|
||||
}
|
||||
|
||||
if (!updatedAny) {
|
||||
this.refreshAllButtons();
|
||||
}
|
||||
}
|
||||
|
||||
private buildNoteStatusServiceForFile(file: TFile): NoteStatusService {
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
return noteStatusService;
|
||||
}
|
||||
|
||||
private createButtonForLeaf(leaf: WorkspaceLeaf) {
|
||||
if (!this.isValidMarkdownLeaf(leaf)) {
|
||||
return;
|
||||
|
|
@ -203,11 +163,15 @@ export class EditorToolbarIntegration {
|
|||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
|
||||
if (!noteStatusService) {
|
||||
const markdownView = leaf.view as MarkdownView;
|
||||
if (!markdownView.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create note status service for this leaf
|
||||
const noteStatusService = new NoteStatusService(markdownView.file);
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
// Create button element
|
||||
const buttonElement = this.createButtonElement(leaf);
|
||||
if (!buttonElement) {
|
||||
|
|
@ -250,10 +214,6 @@ export class EditorToolbarIntegration {
|
|||
}
|
||||
|
||||
private getTargetLeaves(): WorkspaceLeaf[] {
|
||||
if (!settingsService.settings.showEditorToolbarButton) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const leaves: WorkspaceLeaf[] = [];
|
||||
|
||||
if (
|
||||
|
|
@ -338,13 +298,12 @@ export class EditorToolbarIntegration {
|
|||
}
|
||||
|
||||
private renderButtonForLeaf(leafButton: LeafButton): void {
|
||||
const statuses =
|
||||
leafButton.noteStatusService?.getStatusesByAllKeys() || {};
|
||||
leafButton.root.render(
|
||||
<EditorToolbarButton
|
||||
statuses={statuses}
|
||||
defaultTagName={settingsService.settings.tagPrefix}
|
||||
onClick={() => this.openStatusModal(leafButton.leaf)}
|
||||
statuses={leafButton.noteStatusService?.statuses || {}}
|
||||
onClick={() =>
|
||||
this.openStatusModal(leafButton.noteStatusService)
|
||||
}
|
||||
unknownStatusConfig={this.getUnknownStatusConfig()}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -352,12 +311,14 @@ export class EditorToolbarIntegration {
|
|||
|
||||
private refreshAllButtons(): void {
|
||||
for (const [leaf, leafButton] of this.leafButtons.entries()) {
|
||||
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
|
||||
if (!noteStatusService) {
|
||||
continue;
|
||||
const markdownView = leaf.view as MarkdownView;
|
||||
if (markdownView.file) {
|
||||
leafButton.noteStatusService = new NoteStatusService(
|
||||
markdownView.file,
|
||||
);
|
||||
leafButton.noteStatusService.populateStatuses();
|
||||
this.renderButtonForLeaf(leafButton);
|
||||
}
|
||||
leafButton.noteStatusService = noteStatusService;
|
||||
this.renderButtonForLeaf(leafButton);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,37 +340,21 @@ export class EditorToolbarIntegration {
|
|||
this.syncButtons();
|
||||
}
|
||||
|
||||
private openStatusModal(leaf: WorkspaceLeaf) {
|
||||
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
|
||||
private openStatusModal(noteStatusService: NoteStatusService) {
|
||||
if (!noteStatusService) {
|
||||
console.error(
|
||||
"open status modal failed because there is no markdown file in the provided leaf",
|
||||
throw new Error(
|
||||
"open status modal failed because there is no noteStatusService available",
|
||||
);
|
||||
return;
|
||||
}
|
||||
eventBus.publish("triggered-open-modal", {
|
||||
statusService: noteStatusService,
|
||||
});
|
||||
}
|
||||
|
||||
private buildNoteStatusServiceForLeaf(
|
||||
leaf: WorkspaceLeaf,
|
||||
): NoteStatusService | null {
|
||||
if (!this.isValidMarkdownLeaf(leaf)) {
|
||||
return null;
|
||||
}
|
||||
const markdownView = leaf.view as MarkdownView;
|
||||
if (!markdownView.file) {
|
||||
return null;
|
||||
}
|
||||
return this.buildNoteStatusServiceForFile(markdownView.file);
|
||||
}
|
||||
|
||||
private getUnknownStatusConfig() {
|
||||
const settings = settingsService.settings;
|
||||
return {
|
||||
icon: settings.unknownStatusIcon || "❓",
|
||||
lucideIcon: settings.unknownStatusLucideIcon || "",
|
||||
color: settings.unknownStatusColor || "#8b949e",
|
||||
};
|
||||
}
|
||||
|
|
@ -424,7 +369,7 @@ export class EditorToolbarIntegration {
|
|||
"editorToolbarIntegrationSubscription2",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
"editorToolbarIntegrationSubscription3",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Root, createRoot } from "react-dom/client";
|
||||
import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
|
||||
|
||||
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view";
|
||||
|
||||
|
|
@ -21,7 +18,6 @@ interface StatusItem {
|
|||
name: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
lucideIcon?: string;
|
||||
}
|
||||
|
||||
interface FilesByStatus {
|
||||
|
|
@ -61,19 +57,28 @@ export class GroupedDashboardView extends ItemView {
|
|||
name: status.name,
|
||||
color: status.color || "",
|
||||
icon: status.icon,
|
||||
lucideIcon: status.lucideIcon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getFiles();
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
// Create maps for both scoped and legacy status lookup
|
||||
const statusMap = new Map(
|
||||
availableStatuses.map((s) => {
|
||||
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
|
||||
return [key, s];
|
||||
}),
|
||||
);
|
||||
const legacyStatusMap = new Map(
|
||||
availableStatuses.map((s) => [s.name, s]),
|
||||
);
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
|
|
@ -86,27 +91,41 @@ export class GroupedDashboardView extends ItemView {
|
|||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const noteStatusService = new NoteStatusService(tFile);
|
||||
noteStatusService.populateStatuses();
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statusesForKey = noteStatusService.getStatusesForKey(key);
|
||||
if (!statusesForKey.length) return;
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
// Try to find status by exact match first, then by legacy name
|
||||
let resolvedStatus = statusMap.get(statusStr);
|
||||
if (!resolvedStatus) {
|
||||
resolvedStatus = legacyStatusMap.get(statusStr);
|
||||
}
|
||||
|
||||
statusesForKey.forEach((status) => {
|
||||
const statusKey = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
});
|
||||
if (resolvedStatus) {
|
||||
const statusKey = resolvedStatus.templateId
|
||||
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
|
||||
: resolvedStatus.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -134,7 +153,7 @@ export class GroupedDashboardView extends ItemView {
|
|||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"grouped-dashboard-view-subscription",
|
||||
);
|
||||
|
|
@ -147,7 +166,6 @@ export class GroupedDashboardView extends ItemView {
|
|||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "statusFrontmatterMappings" ||
|
||||
key === "strictStatuses"
|
||||
) {
|
||||
onDataChange();
|
||||
|
|
@ -162,7 +180,7 @@ export class GroupedDashboardView extends ItemView {
|
|||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
"grouped-dashboard-view-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
|
|
|
|||
|
|
@ -6,14 +6,10 @@ import {
|
|||
GroupedStatusView as GroupedStatusViewComponent,
|
||||
StatusItem,
|
||||
} from "@/components/GroupedStatusView/GroupedStatusView";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "@/core/noteStatusService";
|
||||
import { BaseNoteStatusService } from "@/core/noteStatusService";
|
||||
import eventBus from "@/core/eventBus";
|
||||
import settingsService from "@/core/settingsService";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
|
||||
|
||||
export const VIEW_TYPE_EXAMPLE = "grouped-status-view";
|
||||
|
||||
|
|
@ -46,19 +42,28 @@ export class GroupedStatusView extends ItemView {
|
|||
name: status.name,
|
||||
color: status.color || "white",
|
||||
icon: status.icon,
|
||||
lucideIcon: status.lucideIcon,
|
||||
});
|
||||
|
||||
private getAllFiles = (): FileItem[] => {
|
||||
const tFiles = BaseNoteStatusService.app.vault.getFiles();
|
||||
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
return tFiles.map(this.convertTFileToFileItem);
|
||||
};
|
||||
|
||||
private processFiles = (files: FileItem[]): GroupedByStatus => {
|
||||
const result: GroupedByStatus = {};
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
// Create maps for both scoped and legacy status lookup
|
||||
const statusMap = new Map(
|
||||
availableStatuses.map((s) => {
|
||||
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
|
||||
return [key, s];
|
||||
}),
|
||||
);
|
||||
const legacyStatusMap = new Map(
|
||||
availableStatuses.map((s) => [s.name, s]),
|
||||
);
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
result[key] = {};
|
||||
|
|
@ -71,27 +76,41 @@ export class GroupedStatusView extends ItemView {
|
|||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
// Find the TFile to get metadata
|
||||
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
|
||||
file.path,
|
||||
) as TFile;
|
||||
if (!tFile) return;
|
||||
|
||||
const noteStatusService = new NoteStatusService(tFile);
|
||||
noteStatusService.populateStatuses();
|
||||
const cachedMetadata =
|
||||
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statusesForKey = noteStatusService.getStatusesForKey(key);
|
||||
if (!statusesForKey.length) return;
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
// Try to find status by exact match first, then by legacy name
|
||||
let resolvedStatus = statusMap.get(statusStr);
|
||||
if (!resolvedStatus) {
|
||||
resolvedStatus = legacyStatusMap.get(statusStr);
|
||||
}
|
||||
|
||||
statusesForKey.forEach((status) => {
|
||||
const statusKey = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
});
|
||||
if (resolvedStatus) {
|
||||
const statusKey = resolvedStatus.templateId
|
||||
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
|
||||
: resolvedStatus.name;
|
||||
if (!result[key][statusKey]) {
|
||||
result[key][statusKey] = [];
|
||||
}
|
||||
result[key][statusKey].push(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -119,7 +138,7 @@ export class GroupedStatusView extends ItemView {
|
|||
|
||||
private subscribeToEvents = (onDataChange: () => void) => {
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
onDataChange,
|
||||
"grouped-status-view-subscription",
|
||||
);
|
||||
|
|
@ -132,7 +151,6 @@ export class GroupedStatusView extends ItemView {
|
|||
key === "useCustomStatusesOnly" ||
|
||||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "statusFrontmatterMappings" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "vaultSizeLimit"
|
||||
) {
|
||||
|
|
@ -148,7 +166,7 @@ export class GroupedStatusView extends ItemView {
|
|||
|
||||
return () => {
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
"grouped-status-view-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
|
|
@ -165,14 +183,14 @@ export class GroupedStatusView extends ItemView {
|
|||
container.addClass("grouped-status-view-container");
|
||||
|
||||
// Check if vault exceeds the size limit
|
||||
const files = BaseNoteStatusService.app.vault.getFiles();
|
||||
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
|
||||
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
|
||||
|
||||
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
|
||||
// Show disabled message
|
||||
container.createEl("div", {
|
||||
cls: "grouped-status-view-disabled",
|
||||
text: `Grouped Status View disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
|
||||
text: `Grouped Status View disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import settingsService from "@/core/settingsService";
|
|||
import eventBus from "@/core/eventBus";
|
||||
import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
|
||||
import { NoteStatus } from "@/types/noteStatus";
|
||||
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
|
||||
|
||||
interface AppWithCommands extends App {
|
||||
commands: {
|
||||
|
|
@ -59,10 +58,10 @@ export class StatusDashboardView extends ItemView {
|
|||
}
|
||||
|
||||
private calculateVaultStats = (): VaultStats => {
|
||||
const files = this.app.vault.getFiles();
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const availableStatuses =
|
||||
BaseNoteStatusService.getAllAvailableStatuses();
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusMetadataKeys = [settingsService.settings.tagPrefix];
|
||||
|
||||
let notesWithStatus = 0;
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
|
|
@ -81,26 +80,49 @@ export class StatusDashboardView extends ItemView {
|
|||
});
|
||||
|
||||
files.forEach((file) => {
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
noteStatusService.populateStatuses();
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
|
||||
if (!frontmatter) return;
|
||||
|
||||
let hasAnyStatus = false;
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statuses = noteStatusService.getStatusesForKey(key);
|
||||
if (!statuses.length) return;
|
||||
const value = frontmatter[key];
|
||||
if (value) {
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
|
||||
hasAnyStatus = true;
|
||||
tagDistribution[key]++;
|
||||
const statusNames = Array.isArray(value) ? value : [value];
|
||||
statusNames.forEach((statusName) => {
|
||||
const statusStr = statusName.toString();
|
||||
|
||||
statuses.forEach((status) => {
|
||||
const scopedIdentifier = status.templateId
|
||||
? `${status.templateId}:${status.name}`
|
||||
: status.name;
|
||||
if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
// Determine the scoped identifier to use
|
||||
let scopedIdentifier: string;
|
||||
|
||||
if (statusStr.includes(":")) {
|
||||
// Already scoped
|
||||
scopedIdentifier = statusStr;
|
||||
} else {
|
||||
// Legacy status - find first template that has this status
|
||||
const firstTemplateWithStatus =
|
||||
availableStatuses.find(
|
||||
(s) => s.name === statusStr && s.templateId,
|
||||
);
|
||||
scopedIdentifier = firstTemplateWithStatus
|
||||
? `${firstTemplateWithStatus.templateId}:${statusStr}`
|
||||
: statusStr; // Fallback to unscoped if no template found
|
||||
}
|
||||
|
||||
// Initialize status if not already present
|
||||
if (
|
||||
!statusDistribution.hasOwnProperty(scopedIdentifier)
|
||||
) {
|
||||
statusDistribution[scopedIdentifier] = 0;
|
||||
}
|
||||
statusDistribution[scopedIdentifier]++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyStatus) {
|
||||
|
|
@ -128,19 +150,10 @@ export class StatusDashboardView extends ItemView {
|
|||
|
||||
const noteStatusService = new NoteStatusService(activeFile);
|
||||
noteStatusService.populateStatuses();
|
||||
const statusMetadataKeys = getAllFrontmatterKeys();
|
||||
const statusesByKey: Record<string, NoteStatus[]> = {};
|
||||
|
||||
statusMetadataKeys.forEach((key) => {
|
||||
const statuses = noteStatusService.getStatusesForKey(key);
|
||||
if (statuses.length) {
|
||||
statusesByKey[key] = statuses;
|
||||
}
|
||||
});
|
||||
|
||||
this.currentNote = {
|
||||
file: activeFile,
|
||||
statuses: statusesByKey,
|
||||
statuses: noteStatusService.statuses,
|
||||
lastModified: activeFile.stat.mtime,
|
||||
};
|
||||
this.renderDashboard();
|
||||
|
|
@ -234,19 +247,12 @@ export class StatusDashboardView extends ItemView {
|
|||
|
||||
private findUnassignedNotes() {
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const statusKeys = getAllFrontmatterKeys();
|
||||
const filesWithoutStatus = files.filter((file) => {
|
||||
const cachedMetadata = this.app.metadataCache.getFileCache(file);
|
||||
const frontmatter = cachedMetadata?.frontmatter;
|
||||
if (!frontmatter) return true;
|
||||
|
||||
return !statusKeys.some((key) => {
|
||||
const value = frontmatter[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
});
|
||||
return (
|
||||
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
|
||||
);
|
||||
});
|
||||
|
||||
if (filesWithoutStatus.length === 0) {
|
||||
|
|
@ -259,7 +265,8 @@ export class StatusDashboardView extends ItemView {
|
|||
);
|
||||
|
||||
// Create a search query to find files without the status tag
|
||||
const query = statusKeys.map((key) => `-[${key}:]`).join(" ");
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const query = `-[${tagPrefix}:]`;
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
|
|
@ -268,10 +275,8 @@ export class StatusDashboardView extends ItemView {
|
|||
}
|
||||
|
||||
private searchBySpecificStatus(statusName: string) {
|
||||
const keys = getAllFrontmatterKeys();
|
||||
const query = keys
|
||||
.map((key) => `[${key}:"${statusName}"]`)
|
||||
.join(" OR ");
|
||||
const tagPrefix = settingsService.settings.tagPrefix;
|
||||
const query = `[${tagPrefix}:"${statusName}"]`;
|
||||
|
||||
// @ts-ignore
|
||||
this.app.internalPlugins.plugins[
|
||||
|
|
@ -300,14 +305,14 @@ export class StatusDashboardView extends ItemView {
|
|||
container.addClass("status-dashboard-view-container");
|
||||
|
||||
// Check if vault exceeds the size limit
|
||||
const files = this.app.vault.getFiles();
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
|
||||
|
||||
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
|
||||
// Show disabled message
|
||||
container.createEl("div", {
|
||||
cls: "status-dashboard-disabled",
|
||||
text: `Dashboard disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
|
||||
text: `Dashboard disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -324,7 +329,7 @@ export class StatusDashboardView extends ItemView {
|
|||
};
|
||||
|
||||
eventBus.subscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
handleVaultChange,
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
|
|
@ -343,7 +348,6 @@ export class StatusDashboardView extends ItemView {
|
|||
key === "customStatuses" ||
|
||||
key === "useMultipleStatuses" ||
|
||||
key === "strictStatuses" ||
|
||||
key === "statusFrontmatterMappings" ||
|
||||
key === "vaultSizeLimit"
|
||||
) {
|
||||
handleVaultChange();
|
||||
|
|
@ -362,7 +366,7 @@ export class StatusDashboardView extends ItemView {
|
|||
async onClose() {
|
||||
// Clean up event listeners
|
||||
eventBus.unsubscribe(
|
||||
"status-changed",
|
||||
"frontmatter-manually-changed",
|
||||
"status-dashboard-vault-subscription",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
|
|
|
|||
157
main.tsx
|
|
@ -1,12 +1,9 @@
|
|||
import { Plugin, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import { Plugin, WorkspaceLeaf } from "obsidian";
|
||||
import StatusBarIntegration from "integrations/status-bar/status-bar";
|
||||
import eventBus from "core/eventBus";
|
||||
import { PluginSettingIntegration } from "./integrations/settings/pluginSettings";
|
||||
import settingsService from "./core/settingsService";
|
||||
import {
|
||||
BaseNoteStatusService,
|
||||
NoteStatusService,
|
||||
} from "./core/noteStatusService";
|
||||
import { BaseNoteStatusService } from "./core/noteStatusService";
|
||||
import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration";
|
||||
import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration";
|
||||
import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration";
|
||||
|
|
@ -20,9 +17,6 @@ import {
|
|||
StatusDashboardView,
|
||||
VIEW_TYPE_STATUS_DASHBOARD,
|
||||
} from "./integrations/views/status-dashboard-view";
|
||||
import { StatusesInfoPopup } from "./integrations/popups/statusesInfoPopupIntegration";
|
||||
import statusStoreManager from "./core/statusStoreManager";
|
||||
import { isExperimentalFeatureEnabled } from "@/utils/experimentalFeatures";
|
||||
|
||||
export default class NoteStatusPlugin extends Plugin {
|
||||
private statusBarIntegration: StatusBarIntegration;
|
||||
|
|
@ -31,12 +25,10 @@ export default class NoteStatusPlugin extends Plugin {
|
|||
private fileExplorerIntegration: FileExplorerIntegration;
|
||||
private commandsIntegration: CommandsIntegration;
|
||||
private editorToolbarIntegration: EditorToolbarIntegration;
|
||||
private experimentalRibbonShortcuts: Map<string, HTMLElement> = new Map();
|
||||
|
||||
async onload() {
|
||||
BaseNoteStatusService.initialize(this.app);
|
||||
await this.loadPluginSettings();
|
||||
await statusStoreManager.initialize(this);
|
||||
|
||||
// INFO: initialize all integrations
|
||||
Promise.all([
|
||||
|
|
@ -58,7 +50,13 @@ export default class NoteStatusPlugin extends Plugin {
|
|||
(leaf) => new StatusDashboardView(leaf),
|
||||
);
|
||||
|
||||
this.syncExperimentalFeatureShortcuts();
|
||||
this.addRibbonIcon("list-tree", "Open grouped status view", () => {
|
||||
this.activateView();
|
||||
});
|
||||
|
||||
this.addRibbonIcon("activity", "Open status dashboard", () => {
|
||||
this.activateDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
async onunload() {
|
||||
|
|
@ -69,18 +67,12 @@ export default class NoteStatusPlugin extends Plugin {
|
|||
this.commandsIntegration?.destroy();
|
||||
this.editorToolbarIntegration?.destroy();
|
||||
this.pluginSettingsIntegration?.destroy();
|
||||
this.experimentalRibbonShortcuts.forEach((el) => el.remove());
|
||||
this.experimentalRibbonShortcuts.clear();
|
||||
|
||||
// Clean up event subscriptions
|
||||
eventBus.unsubscribe(
|
||||
"triggered-open-modal",
|
||||
"main-triggered-open-modal-subscriptor",
|
||||
);
|
||||
eventBus.unsubscribe(
|
||||
"plugin-settings-changed",
|
||||
"main-status-popup-setting-subscriptor",
|
||||
);
|
||||
}
|
||||
|
||||
async activateView() {
|
||||
|
|
@ -149,58 +141,6 @@ export default class NoteStatusPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private syncExperimentalFeatureShortcuts(): void {
|
||||
const shortcuts = this.getExperimentalShortcuts();
|
||||
Object.entries(shortcuts).forEach(([key, config]) => {
|
||||
this.toggleExperimentalRibbon(key, config);
|
||||
});
|
||||
}
|
||||
|
||||
private getExperimentalShortcuts() {
|
||||
return {
|
||||
groupedStatusView: {
|
||||
feature: "groupedStatusView" as const,
|
||||
icon: "list-tree",
|
||||
title: "Open grouped status view",
|
||||
handler: () => this.activateView(),
|
||||
},
|
||||
statusDashboard: {
|
||||
feature: "statusDashboard" as const,
|
||||
icon: "activity",
|
||||
title: "Open status dashboard",
|
||||
handler: () => this.activateDashboard(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toggleExperimentalRibbon(
|
||||
key: string,
|
||||
config: {
|
||||
feature: Parameters<typeof isExperimentalFeatureEnabled>[0];
|
||||
icon: string;
|
||||
title: string;
|
||||
handler: () => void;
|
||||
},
|
||||
): void {
|
||||
const isEnabled = isExperimentalFeatureEnabled(config.feature);
|
||||
const existingIcon = this.experimentalRibbonShortcuts.get(key);
|
||||
|
||||
if (isEnabled && !existingIcon) {
|
||||
const ribbonEl = this.addRibbonIcon(
|
||||
config.icon,
|
||||
config.title,
|
||||
config.handler,
|
||||
);
|
||||
this.experimentalRibbonShortcuts.set(key, ribbonEl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEnabled && existingIcon) {
|
||||
existingIcon.remove();
|
||||
this.experimentalRibbonShortcuts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEventBus() {
|
||||
// Propagate to custom event bus the new active file
|
||||
this.app.workspace.on(
|
||||
|
|
@ -213,91 +153,18 @@ export default class NoteStatusPlugin extends Plugin {
|
|||
// Propagate to custom event bus the manually frontmatter data
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", (file) => {
|
||||
eventBus.publish("status-changed", { file });
|
||||
eventBus.publish("frontmatter-manually-changed", { file });
|
||||
}),
|
||||
);
|
||||
|
||||
// Register listeners
|
||||
eventBus.subscribe(
|
||||
"triggered-open-modal",
|
||||
({ statusService, activeStatus }) => {
|
||||
StatusModalIntegration.open(
|
||||
this.app,
|
||||
statusService,
|
||||
activeStatus,
|
||||
);
|
||||
({ statusService }) => {
|
||||
StatusModalIntegration.open(this.app, statusService);
|
||||
},
|
||||
"main-triggered-open-modal-subscriptor",
|
||||
);
|
||||
|
||||
eventBus.subscribe(
|
||||
"plugin-settings-changed",
|
||||
({ key, value }) => {
|
||||
if (key === "enableStatusOverviewPopup" && value === false) {
|
||||
StatusesInfoPopup.close();
|
||||
}
|
||||
if (
|
||||
key === "enableExperimentalFeatures" ||
|
||||
key === "enableStatusDashboard" ||
|
||||
key === "enableGroupedStatusView"
|
||||
) {
|
||||
this.syncExperimentalFeatureShortcuts();
|
||||
}
|
||||
},
|
||||
"main-status-popup-setting-subscriptor",
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", async (file) => {
|
||||
if (
|
||||
file instanceof TFile &&
|
||||
file.extension === "md" &&
|
||||
settingsService.settings.defaultStatusForNewNotes
|
||||
) {
|
||||
// Wait a bit to ensure metadata is processed or templates are applied
|
||||
setTimeout(async () => {
|
||||
// Ensure file still exists
|
||||
if (!this.app.vault.getAbstractFileByPath(file.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteStatusService = new NoteStatusService(file);
|
||||
// Populate statuses to see if it already has any
|
||||
noteStatusService.populateStatuses();
|
||||
|
||||
const groupedStatuses =
|
||||
noteStatusService.getStatusesByAllKeys();
|
||||
const hasStatuses = Object.values(groupedStatuses).some(
|
||||
(statuses) => statuses.length > 0,
|
||||
);
|
||||
|
||||
if (!hasStatuses) {
|
||||
await noteStatusService.addStatus(
|
||||
settingsService.settings.tagPrefix,
|
||||
settingsService.settings
|
||||
.defaultStatusForNewNotes as string,
|
||||
);
|
||||
}
|
||||
}, 500); // Increased delay to allow more time for templates
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", (file) => {
|
||||
if (file instanceof TFile) {
|
||||
eventBus.publish("status-changed", { file });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("delete", (file) => {
|
||||
if (file instanceof TFile) {
|
||||
eventBus.publish("status-changed", { file });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async loadPluginSettings() {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"id": "note-status",
|
||||
"name": "Note Status",
|
||||
"version": "3.1.2",
|
||||
"version": "2.2.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Track and change note statuses across your vault with templates, bulk folder actions, dashboards, and non-Markdown support.",
|
||||
"description": "Enhance your note organization with a status management system. Assign and track statuses (active, on hold, completed, dropped) for your notes with a customizable interface including status pane, dropdown, and file explorer integration.",
|
||||
"author": "Aleix Soler",
|
||||
"authorUrl": "https://aleixsoler.com",
|
||||
"fundingUrl": {
|
||||
"PayPal": "https://paypal.me/aleixsoler",
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com/aleixsoler"
|
||||
"PayPal": "https://paypal.me/aleixsoler"
|
||||
},
|
||||
"isDesktopOnly": false,
|
||||
"license": "MIT",
|
||||
"icon": "tag",
|
||||
"repository": "https://github.com/devonthesofa/obsidian-note-status",
|
||||
"issues": "https://github.com/devonthesofa/obsidian-note-status/issues",
|
||||
"releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.",
|
||||
"css": "styles.css"
|
||||
}
|
||||
|
|
|
|||
3205
pnpm-lock.yaml
1420
styles.css
|
|
@ -1,62 +0,0 @@
|
|||
/* ==========================================================================
|
||||
Change Status Modal
|
||||
========================================================================== */
|
||||
|
||||
.status-selector-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.status-selector-group:focus-visible {
|
||||
outline: 2px solid var(--background-modifier-border-hover);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.note-status-template-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--background-modifier-border-hover);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--background-secondary-alt);
|
||||
margin-bottom: var(--size-4-3);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.note-status-template-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: var(--size-2-2) var(--size-4-2);
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--font-ui-small);
|
||||
transition: all var(--anim-duration-fast) ease;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-status-template-tab:not(.is-active):hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.note-status-template-tab.is-active {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 1px var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.note-status-template-tab:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.1),
|
||||
0 0 0 2px var(--interactive-accent) inset;
|
||||
}
|
||||
|
|
@ -11,62 +11,6 @@
|
|||
right: 0;
|
||||
}
|
||||
|
||||
.note-status-colored-block {
|
||||
position: relative;
|
||||
border-radius: var(--radius-s);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.note-status-colored-block::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: var(--note-status-block-color, transparent);
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
transition:
|
||||
background-color var(--anim-duration-fast) ease,
|
||||
opacity var(--anim-duration-fast) ease;
|
||||
}
|
||||
|
||||
.note-status-border-left {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-status-border-left::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 3px;
|
||||
border-radius: var(--radius-s);
|
||||
background-color: var(--note-status-border-color, transparent);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.note-status-underline {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-decoration-color: var(--note-status-underline-color, currentColor);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.note-status-dot-badge {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: var(--size-2-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 0 1px var(--background-modifier-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
|
@ -90,17 +34,6 @@
|
|||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.status-minimal--compact {
|
||||
margin-left: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
|
||||
.status-minimal--compact .status-minimal__icon {
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
/* Use data attribute for dynamic colors */
|
||||
.status-minimal[data-status-color] .status-minimal__count {
|
||||
background: var(--interactive-accent);
|
||||
|
|
@ -109,9 +42,6 @@
|
|||
.status-minimal__icon {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: grayscale(0.2);
|
||||
transition: filter var(--anim-duration-fast) ease;
|
||||
}
|
||||
|
|
@ -172,54 +102,6 @@
|
|||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.status-tag-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-4-2);
|
||||
}
|
||||
.status-tag-card {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
padding: var(--size-4-2);
|
||||
background: var(--background-primary-alt);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.status-tag-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size-2-2);
|
||||
}
|
||||
.status-tag-card__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-2-1);
|
||||
}
|
||||
.status-tag-card__label {
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: none;
|
||||
}
|
||||
.status-tag-card__badge {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
background: var(--background-modifier-border);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-s);
|
||||
width: fit-content;
|
||||
}
|
||||
.status-tag-card__count {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
background: var(--background-modifier-border);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-s);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
.status-tag-card__statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-2-2);
|
||||
}
|
||||
|
||||
/* Status Groups in Popup */
|
||||
.status-group {
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
/* ==========================================================================
|
||||
Shared Form Controls
|
||||
========================================================================== */
|
||||
|
||||
.note-status-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
padding: var(--size-2-2) var(--size-2-3);
|
||||
transition:
|
||||
border-color var(--anim-duration-fast) ease,
|
||||
box-shadow var(--anim-duration-fast) ease;
|
||||
}
|
||||
|
||||
.note-status-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--interactive-accent);
|
||||
box-shadow: 0 0 0 2px var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.note-status-input--search {
|
||||
padding-left: var(--size-4-1);
|
||||
}
|
||||
|
||||
.note-status-input--color {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
padding: var(--size-2-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note-status-input--color::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.note-status-input--color::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-status-input:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -78,18 +78,25 @@
|
|||
|
||||
/* Status Group */
|
||||
.grouped-status-group {
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: var(--size-4-2);
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-m);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow var(--anim-duration-fast) ease;
|
||||
}
|
||||
|
||||
.grouped-status-group:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.grouped-status-group__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
padding: var(--size-4-2) var(--size-4-3);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-s);
|
||||
transition: background var(--anim-duration-fast) ease;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -98,8 +105,8 @@
|
|||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.grouped-status-group__header:focus-visible {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
.grouped-status-group__header:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
|
@ -131,46 +138,40 @@
|
|||
|
||||
/* Files List */
|
||||
.grouped-status-files {
|
||||
margin-bottom: var(--size-2-2);
|
||||
background: var(--background-secondary);
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.grouped-status-files-list {
|
||||
max-height: 400px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grouped-status-file-item {
|
||||
padding: var(--size-2-3) var(--size-4-3);
|
||||
cursor: pointer;
|
||||
transition: background var(--anim-duration-fast) ease;
|
||||
border-radius: var(--radius-s);
|
||||
margin-top: 1px;
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.grouped-status-file-item:hover .tree-item-self {
|
||||
.grouped-status-file-item:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.grouped-status-file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
.grouped-status-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.grouped-status-file-name {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-normal);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.grouped-status-file-path {
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Load More */
|
||||
|
|
|
|||