Compare commits

..

140 commits

Author SHA1 Message Date
Aleix Soler
ae70d96616 chore: bump version 2026-06-04 19:39:03 +02:00
Aleix Soler
00a2673a7a
Merge pull request #103 from BravishkaSkytano/master
Add digital garden workflow configuration
2026-06-04 19:32:43 +02:00
Yoela O.
680bd79caa
Add digital garden workflow configuration
This JSON file defines a workflow for a digital garden setup, including various statuses with descriptions and icons.
2026-05-24 21:05:23 -04:00
Aleix Soler
1be0407711 chore: bump version 2026-04-28 19:27:19 +02:00
Aleix Soler
fc43bbb46e Merge branch 'pr-102' 2026-04-28 19:18:29 +02:00
Aleix Soler
1cb696bad2
Merge pull request #102 from JuliaHusar/master
Create research-note-taking-template
2026-04-28 19:15:17 +02:00
Aleix Soler
b1ddeec8ce fix: add .json extension to research template for automatic registration 2026-04-28 19:07:49 +02:00
Julia Husar
8e9d1b15f3
Create research-note-taking-template
A template for those who want to go back to existing notes and update them as they find more information and build knowledge.
2026-04-27 10:21:38 -04:00
Aleix Soler
38b4558502 chore: bump version 2026-03-19 19:29:38 +01:00
Aleix Soler
bed56422aa
Merge pull request #100 from devonthesofa/feature/improve-status-group
improve status group UI
2026-03-19 19:15:01 +01:00
Aleix Soler
6aee0cb2fe fix: resolve linting errors from mixed spaces and tabs 2026-03-19 19:12:06 +01:00
Aleix Soler
2c2198ad3c style(GroupedStatusView): update CSS to match Obsidian native tree-item structure 2026-03-19 19:12:06 +01:00
Aleix Soler
f081d0a852 feat(GroupedStatusView): adopt Obsidian native tree-item UI in StatusGroup and FileList 2026-03-19 19:12:06 +01:00
Aleix Soler
005a9ae5ad refactor(GroupedStatusView): sort statuses by user settings order in TagSection 2026-03-19 19:12:06 +01:00
Aleix Soler
f1fd126ec6
Merge pull request #99 from devonthesofa/feature/notebook-navigator-integration
notebook navigator integration + fix renaming file
2026-03-19 18:55:14 +01:00
Aleix Soler
7727605e6f
Merge pull request #98 from devonthesofa/feature/improve-status-selector
improve status selector modal
2026-03-19 18:52:44 +01:00
Aleix Soler
fd7004d3a8
Merge pull request #97 from devonthesofa/feature/templates-caracterization
Templates caracterization
2026-03-19 18:51:06 +01:00
Aleix Soler
4ad524b32c
Merge pull request #96 from devonthesofa/fix/legacy-templates
Predefined templates
2026-03-19 18:49:40 +01:00
Aleix Soler
f4dc6b77ec fix(file-explorer): inject icon as sibling to prevent inclusion in filename during rename 2026-03-19 18:44:40 +01:00
Aleix Soler
b26f0c201b feat(file-explorer): add Notebook Navigator compatibility 2026-03-19 18:35:28 +01:00
Aleix Soler
019e70a899 style(modals): apply interactive-accent to active template tab to improve contrast 2026-03-19 18:17:43 +01:00
Aleix Soler
cb3ab49efa style(modals): replace high-contrast success color with native obsidian accent variables for active items 2026-03-19 18:12:58 +01:00
Aleix Soler
9ba2619496 style(modals): redesign status selector and template tabs with premium segmented control aesthetics 2026-03-19 18:07:15 +01:00
Aleix Soler
c1b1b76a90 refactor(events): propagate clicked activeStatus payload from UI components to modal integrations 2026-03-19 12:51:49 +01:00
Aleix Soler
1bf7f3d8fb feat(modals): auto-select the active status template tab when opening status selector 2026-03-19 12:51:47 +01:00
Aleix Soler
87072c5617 fix(templates): correctly evaluate custom vs marketplace templates based on internal lists instead of checking if it has an author 2026-03-19 12:34:12 +01:00
Aleix Soler
7462a6c902 refactor(templates): assign basic taxonomy metadata to built-in templates 2026-03-19 12:34:09 +01:00
Aleix Soler
db41101236 feat(marketplace): add dynamic language and type filters to marketplace 2026-03-19 12:34:07 +01:00
Aleix Soler
5276248671 feat(templates): add language and type metadata to templates and creation modal 2026-03-19 12:34:05 +01:00
Aleix Soler
1e01aac2af feat(ui): allow custom editable template IDs during creation and unlockable inputs 2026-03-19 12:07:10 +01:00
Aleix Soler
9852f96a3c chore(build): update compiled css 2026-03-19 11:52:34 +01:00
Aleix Soler
53e80f55b8 feat(ui): distinguish predefined vs community templates in marketplace 2026-03-19 11:52:32 +01:00
Aleix Soler
da3bb8eeb1 fix(settings): restore predefined templates as default and auto-enable them 2026-03-19 11:52:30 +01:00
Aleix Soler
630538037a refactor(templates): separate predefined templates into subdirectory and update build script 2026-03-19 11:52:27 +01:00
Aleix Soler
4001c7bd9f
Merge pull request #91 from devonthesofa/feature/templates-marketplace
Templates marketplace
2026-03-18 19:43:04 +01:00
Aleix Soler
76a2e2bac2
Merge branch 'master' into feature/templates-marketplace 2026-03-18 19:42:54 +01:00
Aleix Soler
75f0619f1e
Merge pull request #94 from devonthesofa/feature/default-status
feat: add support for default status on new notes
2026-03-18 19:40:20 +01:00
Aleix Soler
d7ba7dd7cc feat: add support for default status on new notes
- Added 'defaultStatusForNewNotes' setting to PluginSettings.
- Added a dropdown in Behaviour settings to select the default status.
- Implemented a 'create' event listener in main.tsx to automatically apply the default status to new markdown files if they don't have any status.
- Included the new setting in the 'workflow' synchronization group.
- Fixed mixed spaces/tabs in pluginSettings.ts.
2026-03-18 19:38:17 +01:00
Aleix Soler
94da64ec69
Merge pull request #92 from devonthesofa/fix/ui-problems-with-new-obsidian-version
Fix/UI problems with new obsidian version
2026-03-18 19:27:12 +01:00
Aleix Soler
12027c5ed2 ui: improve the title description 2026-03-18 19:24:50 +01:00
Aleix Soler
3d49c32f18 ui: enhance support section with copy-to-clipboard buttons and modern styling 2026-03-18 19:15:04 +01:00
Aleix Soler
782e9f14a8 fix: preserve original template IDs during migration and installation for legacy compatibility 2026-03-18 19:02:33 +01:00
Aleix Soler
712ab7bc6b
Rename starter-template.json to default-starter.json 2026-03-18 18:35:56 +01:00
Aleix Soler
fcd5d114ec chore: add starer template to templates 2026-03-18 18:34:52 +01:00
Aleix Soler
9351ccc4da chore: set the starter template for new users 2026-03-18 18:19:38 +01:00
Aleix Soler
e141c8c729 refactor: replace separate author and github fields with authorGithub username 2026-03-18 18:10:00 +01:00
Aleix Soler
0f810a79e1 feat: automate marketplace template registration during build process 2026-03-18 17:53:23 +01:00
Aleix Soler
bd0469f7c7 refactor: use templates/ folder as single source of truth and cleanup unused code 2026-03-18 17:25:48 +01:00
Aleix Soler
2537227678 chore: cleanup unused code and enhance marketplace author links 2026-03-18 16:58:41 +01:00
Aleix Soler
6ab760df98 feat: improve template marketplace UI/UX with badges, 'Modified' status, and read-only marketplace metadata 2026-03-18 16:45:47 +01:00
Aleix Soler
4cf73df730 chore: update marketplace templates with official author metadata 2026-03-18 16:42:22 +01:00
Aleix Soler
04c4bf70fd chore: start with empty templates by default for new users 2026-03-18 16:20:07 +01:00
Aleix Soler
7833676b8b fix: implement template deduplication and installation safety checks 2026-03-18 16:20:01 +01:00
Aleix Soler
a51b1ad4c3 fix: resolve re-installation bug by matching marketplace templates by name 2026-03-18 16:14:25 +01:00
Aleix Soler
74262f9b86 fix: resolve build errors in TemplateSettings and MarketplaceBrowseModal 2026-03-18 16:11:26 +01:00
Aleix Soler
e089efd85c feat: implement Template Marketplace gallery with installation workflow 2026-03-18 15:57:07 +01:00
Aleix Soler
0d5cf5a0d8 docs: update marketplace submission workflow with manual steps and repository link 2026-03-18 15:49:57 +01:00
Aleix Soler
563d9fb202 feat: implement MarketplaceShareModal for reliable template submission 2026-03-18 15:43:10 +01:00
Aleix Soler
307163b3b7 fix: use anchor tag for marketplace submission to improve reliability 2026-03-18 15:39:37 +01:00
Aleix Soler
6a643548ad feat: implement 'Submit to Marketplace' workflow for custom templates 2026-03-18 15:34:35 +01:00
Aleix Soler
2abccbc87d chore: regenerate predefined templates 2026-03-18 15:07:41 +01:00
Aleix Soler
26cb95b805 docs: add marketplace contribution guide and update build scripts 2026-03-18 15:07:37 +01:00
Aleix Soler
a2fffec37c feat: add author attribution support to status templates UI 2026-03-18 15:07:31 +01:00
Aleix Soler
3b8efa9977 feat: add template marketplace infrastructure and JSON templates 2026-03-18 15:04:12 +01:00
Aleix Soler
07c8b8d4df
Merge pull request #90 from devonthesofa/feature/syncronize-settings
feat: implement multi-device synchronization for plugin settings
2026-03-18 12:48:05 +01:00
Aleix Soler
32af8e1c78 feat: add manual non-Markdown data export/import to vault
- Implement exportToVault and importFromVault in NonMarkdownStatusStore
- Add getNonMarkdownStore method to StatusStoreManager
- Add manual synchronization buttons to the Synchronization settings UI
- Support data migration between internal storage and vault sync file
2026-03-18 12:43:18 +01:00
Aleix Soler
f97bca5fa9 feat: implement granular selective synchronization and non-Markdown status sync
- Expand sync categories to include templates, custom statuses, colors, UI, workflow, storage, and features
- Add support for syncing non-Markdown status data to a vault-based JSON file
- Update NonMarkdownStatusStore to handle vault-based storage and real-time updates
- Enhance Synchronization UI with granular group selection and data sync options
- Add safety checks to prevent TypeErrors during settings initialization and migration
2026-03-18 12:35:49 +01:00
Aleix Soler
e5c456b0aa feat: implement selective settings synchronization 2026-03-18 11:45:54 +01:00
Aleix Soler
403c4113e4 fix: allow manual settings sync when automatic sync is disabled 2026-03-18 11:39:40 +01:00
Aleix Soler
2fe31700f9 feat: implement multi-device synchronization for plugin settings
- Add external JSON file synchronization for all plugin settings
- Implement automatic sync and vault watcher for real-time updates
- Fix data loss bug by deep merging status colors in SettingsService
- Add Synchronization section to Settings UI with manual export/import
2026-03-18 11:32:38 +01:00
Aleix Soler
cbb9c0f531 feat: v3.0.0 manifest 2025-11-24 19:16:38 +01:00
Aleix Soler
4d4e982f68
Merge pull request #87 from devonthesofa/chore/wiki
[chore] Improved readme and docs
2025-11-24 19:06:56 +01:00
Aleix Soler
d456f0a6ae
Update README.md 2025-11-24 19:03:02 +01:00
Aleix Soler
5d53927008 chore: images 2025-11-24 19:00:22 +01:00
Aleix Soler
0a8b4f72eb chore: add readme 2025-11-24 18:53:24 +01:00
Aleix Soler
19f69b19ff Merge branch 'chore/wiki' of github.com:devonthesofa/obsidian-note-status into chore/wiki 2025-11-24 18:51:32 +01:00
Aleix Soler
6ca2632c83 chore: add buymeacoffe 2025-11-24 18:51:27 +01:00
Aleix Soler
9c1c7eefd9
Enhance README with clearer project description
Updated project description for clarity and detail. Added additional information about functionality and usage.
2025-11-24 18:47:43 +01:00
Aleix Soler
b90f414a85 chore: added images 2025-11-24 18:29:41 +01:00
Aleix Soler
87d865de3b chore: improved readme and docs 2025-11-23 19:02:05 +01:00
Aleix Soler
063a4aac01
Merge pull request #86 from devonthesofa/refactor/settings-with-tabs
Refactor/settings with tabs
2025-11-23 18:42:14 +01:00
Aleix Soler
af606fddbe feat: reestructured settings 2025-11-23 18:17:17 +01:00
Aleix Soler
8ab5212a2c feat: settings collpased 2025-11-23 18:11:19 +01:00
Aleix Soler
b6b505b139
Merge pull request #85 from devonthesofa/refactor/ui
[refactor] Refresh status UI with shared styling and Obsidian-aligned components
2025-11-22 11:33:50 +01:00
Aleix Soler
6c4dcbc293 feat: make status bar badges reactive and add content modes 2025-11-22 11:32:02 +01:00
Aleix Soler
5ddecab6ee feat: add configurable badge styles to status bar 2025-11-22 11:23:07 +01:00
Aleix Soler
c21f1eb5db feat: refresh status UI with shared styling and Obsidian-aligned components 2025-11-22 11:10:26 +01:00
Aleix Soler
ad844383ae
Merge pull request #82 from devonthesofa/feature/relate-tags-with-templates
[feature] Fronttmatter tag by template
2025-11-21 19:19:43 +01:00
Aleix Soler
3dbd0b26a7 chore: remove comment 2025-11-21 19:14:50 +01:00
Aleix Soler
45e2049f7b feat: redesign status hover popup with per-tag groups and accurate data feed 2025-11-21 19:11:48 +01:00
Aleix Soler
298deddf9d fix: write mapped statuses to every configured tag per template 2025-11-21 18:59:07 +01:00
Aleix Soler
82f277e66b feat: add setting to mirror mapped statuses into default tag 2025-11-21 18:54:03 +01:00
Aleix Soler
d3f358bc96 refactor: centralize frontmatter key handling and expose per-key status reads 2025-11-21 18:42:55 +01:00
Aleix Soler
b222295109 feat: support frontmatter key mappings while keeping single-key UI 2025-11-21 18:21:16 +01:00
Aleix Soler
754d5002d4 feat: fronttmatter tag by template 2025-11-21 17:10:10 +01:00
Aleix Soler
3cfd46b848
Merge pull request #81 from devonthesofa/feature/experiments-beta-check
[feature] Gate dashboard and grouped status view behind experimental settings
2025-11-21 16:04:14 +01:00
Aleix Soler
c3659363e8 chore: clean-up 2025-11-21 16:02:23 +01:00
Aleix Soler
bad92c6344 feat: gate dashboard and grouped status view behind experimental settings 2025-11-21 15:46:13 +01:00
Aleix Soler
edacb2a9fa
Merge pull request #79 from devonthesofa/feature/file-explorer-rainbow
[feature] Add advanced file explorer status visualizations
2025-11-21 15:34:55 +01:00
Aleix Soler
7bb982ce2c feat: multiple visual styles 2025-11-21 15:31:01 +01:00
Aleix Soler
48945b3d63 feat: tint enitre file item block 2025-11-21 14:57:38 +01:00
Aleix Soler
a13adb265d refactor: reuse color logic 2025-11-21 14:45:31 +01:00
Aleix Soler
da2572398d feat: color file explorer note titles based on status 2025-11-21 14:38:17 +01:00
Aleix Soler
16bcf02039
Merge pull request #76 from devonthesofa/fix/bubble-to-non-status
[fix] Reset editor toolbar badge when active note has no status
2025-11-21 14:23:26 +01:00
Aleix Soler
3a2ad4ec72 fix: reset editor toolbar badge when active note has no status 2025-11-21 14:14:21 +01:00
Aleix Soler
03021c3efd
Merge pull request #75 from devonthesofa/feat/non-markdown-support
[feature] Status for the non-markdown files
2025-11-21 13:30:11 +01:00
Aleix Soler
ee94f6b13c feat: extension to non-markdown files 2025-11-21 12:51:08 +01:00
Aleix Soler
6ea99c29fa
Merge pull request #73 from devonthesofa/feature/lucid-icons-support
[Feature] Support Lucide icons in statuses
2025-11-21 09:49:08 +01:00
Aleix Soler
77d2745b39 chore: reuse prop 2025-11-21 09:45:00 +01:00
Aleix Soler
ecb24412aa fix: use either icon or lucideIcon 2025-11-21 09:30:10 +01:00
Aleix Soler
2e7c3ad66c chore: icon selection field added 2025-11-21 09:13:22 +01:00
Aleix Soler
e5233f6f0e feat: fuzzy finder for lucide icons 2025-11-21 08:57:08 +01:00
Aleix Soler
85c0e6be5c feat: add lucid icon modal 2025-11-21 08:51:00 +01:00
Aleix Soler
9535296919 chore: show all lucide icons 2025-11-21 08:39:40 +01:00
Aleix Soler
71706a440f chore: improve lucide-icon selector 2025-11-21 08:33:25 +01:00
Aleix Soler
1b26ba6d80 feat: add lucid icons support 2025-11-21 08:04:30 +01:00
Aleix Soler
e4eb51108b
Merge pull request #72 from devonthesofa/fix/status-picker-colors
[feature] Unify template selector icon styling with file explorer
2025-11-18 19:12:45 +01:00
Aleix Soler
4f6adc88a1 fix: remove core from component 2025-11-18 19:10:31 +01:00
Aleix Soler
9b36b0179a feat: align status modal icons with explorer styles and polish selector layout 2025-11-18 19:05:48 +01:00
Aleix Soler
76687fb25d
Merge pull request #70 from devonthesofa/feature/single-status-note-as-string
[feature] Add setting to store status as string or list
2025-11-18 18:52:06 +01:00
Aleix Soler
8142f37c09 feat: add configurable single-status frontmatter storage 2025-11-18 18:48:58 +01:00
Aleix Soler
4cb57d410f
Merge pull request #69 from devonthesofa/feature/feat-bulk-by-folder
[feature] Add bulk folder status action with recursive option
2025-11-18 18:28:55 +01:00
Aleix Soler
965f81187a feat: Add bulk folder status action with recursive option and settings toggle 2025-11-18 18:18:43 +01:00
Aleix Soler
aa3ee7e963
Merge pull request #68 from devonthesofa/feature/status-selector-ui
[feature] Add template-based status grouping with switchable views in status picker
2025-11-18 17:45:00 +01:00
Aleix Soler
853916e5e6 feat: add status templates grouping 2025-11-18 17:37:44 +01:00
Aleix Soler
abd68d00e6
Merge pull request #66 from devonthesofa/fix/file-explorer-icon-colors
[Fix/] file explorer icon colors
2025-11-17 20:09:00 +01:00
Aleix Soler
70c83462b2 chore: new styles 2025-11-17 20:07:50 +01:00
Aleix Soler
97ac64b6a4 feat: add icon color mode 2025-11-17 19:41:48 +01:00
Aleix Soler
3680eb7647 feat: Add file explorer icon frame setting with always/never options 2025-11-17 19:39:02 +01:00
Aleix Soler
b0cb600301 fix: apply status colors to file explorer icons 2025-11-17 19:21:38 +01:00
Aleix Soler
d060095bde
Merge pull request #64 from devonthesofa/feature/improve-settings-organization
[feature] Improve settings grouping and quick-command refresh
2025-11-17 19:01:40 +01:00
Aleix Soler
3297dd6757 fix: refresh quick command settings when custom-only mode changes 2025-11-17 18:54:02 +01:00
Aleix Soler
7b601788f0 fix: reorganize and clarify settings UI 2025-11-17 18:50:14 +01:00
Aleix Soler
fe719ac71c
Merge pull request #63 from devonthesofa/fix/editor-toolbar-button
[fix] editor toolbar button
2025-11-17 18:39:53 +01:00
Aleix Soler
8fe0fdcddc fix: Fix editor toolbar button to refresh per leaf and act on the current note 2025-11-17 18:35:01 +01:00
Aleix Soler
63a6097a4a fix: remove editor toolbar buttons when the toggle is off 2025-11-17 18:24:31 +01:00
Aleix Soler
658de2cebc
Merge pull request #62 from devonthesofa/fix/show-status-bar
[fix] remove status bar stub when “Show status bar” is disabled
2025-11-17 18:17:44 +01:00
Aleix Soler
e4ba8a0127 fix: remove status bar stub when “Show status bar” is disabled 2025-11-17 18:15:42 +01:00
Aleix Soler
31864c943c
Merge pull request #61 from devonthesofa/feature/disable-status-overview
[feature] Add setting to toggle status overview popup
2025-11-17 18:06:40 +01:00
Aleix Soler
8ae9942ea8 feat: add setting to toggle status overview popup 2025-11-17 18:01:05 +01:00
126 changed files with 11078 additions and 3012 deletions

227
README.md
View file

@ -1,149 +1,148 @@
# Note Status for Obsidian # Note Status for Obsidian
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/aleixsoler)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/aleixsoler)
[![GitHub release](https://img.shields.io/github/v/release/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/releases) [![GitHub release](https://img.shields.io/github/v/release/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/devonthesofa/obsidian-note-status/release.yml)](https://github.com/devonthesofa/obsidian-note-status/actions) [![Build Status](https://img.shields.io/github/actions/workflow/status/devonthesofa/obsidian-note-status/release.yml)](https://github.com/devonthesofa/obsidian-note-status/actions)
[![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22note-status%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)](https://obsidian.md/plugins?id=note-status) [![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22note-status%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)](https://obsidian.md/plugins?id=note-status)
[![GitHub License](https://img.shields.io/github/license/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/blob/master/LICENSE) [![GitHub License](https://img.shields.io/github/license/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/blob/master/LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/devonthesofa/obsidian-note-status)](https://github.com/devonthesofa/obsidian-note-status/stargazers)
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. 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.
![Status assignment workflow demonstration](images/status-assignment-demo.png) 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.
_Alt: Demonstration of assigning status to a note using the toolbar dropdown_ ## Contents
## 🎯 Features - [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.
- **📋 Status Assignment** - Mark notes with workflow statuses (active, completed, on hold, etc.) ![Big picture 1](images/big-picture-1.png)
- **🎯 Multi-Status Support** - Assign multiple statuses per note ![Big picture 1](images/big-picture-2.png)
- **📁 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)
![File explorer with status icons](images/file-explorer-status-icons.png) ## Quick start
_Alt: File explorer showing multiple notes with different status icons positioned next to note names and the tooltip with status overview_ 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.
![Status dashboard with vault statistics](images/status-dashboard-overview1.png) ## Key features
_Alt: Status dashboard showing current note information, vault statistics with total notes and coverage percentages_ - Status picker everywhere: status bar badge, editor toolbar, explorer context menus, commands.
![Status dashboard with vault statistics](images/status-dashboard-overview2.png) - 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 distribution chart showing different statuses with colored bars and note counts for each status type_ ## Data model
![Status dashboard with vault statistics](images/status-dashboard-overview3.png)
_Alt: Quick actions panel with organized sections for views, current note operations, clipboard actions, and tools_ - **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.
## 💻 Technology Stack ## Screenshots
Built with modern web technologies for performance and reliability: - Nav bar with current status
- **TypeScript** - Type-safe development - ![navbar-unnasigned](images/navbar-unnasigned.png)
- **React** - Modern UI framework - ![navbar-multiple](images/navbar-multiple.png)
- **esbuild** - Fast bundling and development
- **Obsidian API** - Deep vault integration
## 📦 Installation - Status assignment:
### Community Plugin Store (Recommended) - ![change-not-status-modal](images/change-not-status-modal.png)
1. Open Obsidian → Settings → Community plugins - File explorer with status icons and hover overview:
2. Disable Safe mode → Browse → Search "Note Status" - ![file-explorer-and-popup](images/file-explorer-and-popup.png)
3. Install and Enable - Multiple settings:
- ![settings-collapsed](images/settings-collapsed.png)
- Custom templates editor:
- ![settings-collapsed](images/status-templates-1.png)
- ![settings-collapsed](images/custom-templates.png)
- Frontmatter tags mapping
- ![frontmatter-mappings](images/frontmatter-mappings.png)
- Grouped status view:
- ![status-groups-full-picture-2](images/status-groups-full-picture-2.png)
- Status dashboard:
- ![status-dashboard-1](images/status-dashboard-1.png)
- ![status-dashboard-2](images/status-dashboard-2.png)
- ![status-dashboard-3](images/status-dashboard-3.png)
### Manual Installation ## Template Marketplace
1. Download latest release from [GitHub Releases](https://github.com/devonthesofa/obsidian-note-status/releases) You can contribute your own status templates to the plugin!
2. Extract `main.js`, `manifest.json`, and `styles.css` to `.obsidian/plugins/note-status/`
3. Enable in Community plugins settings
## 🚀 How to Use ### How to contribute (The Easy Way)
### Quick Start 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!
1. **Assign Status**: ### How to contribute (Manual)
1. Click the "No status" in toolbar 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!
![Status toolbar](images/no-status-toolbar.png) Once accepted and merged, your template will be automatically included in the next build and available in the marketplace for all users.
_Alt: Editor toolbar showing "No status" button for status assignment_ ## Installation
2. → Select a status ### Community Plugin Store (recommended)
2. **View in Explorer**: Status icons appear next to note names 1. Obsidian → Settings → Community plugins.
2. Disable Safe mode → Browse → search “Note Status”.
3. Install and enable.
![File explorer item](images/file-explorer-item.png) ### Manual
_Alt: Single file explorer item showing a note with status icon positioned next to the filename_ 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.
3. **Browse by Status or open the Dashboard**: Open from left sidebar ## Example workflows
![Open status view](images/open-view-buttons.png) **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
![Multiple status context](images/change-multiple-statuses.png)
_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 💡 idea → 📝 draft → ✏️ editing → ✅ completed → 📦 archived
``` ```
**Project Management** **Project delivery**
``` ```
📋 backlog → 🚦 ready → 👨‍💻 inDevelopment → 🧪 testing → 🚀 live 📋 backlog → 🚦 ready → 👨‍💻 inDevelopment → 🧪 testing → 🚀 live
``` ```
**Academic Research** **Academic**
``` ```
🔍 research → 📑 outline → ✏️ draft → 🔬 review → 📚 final 🔍 research → 📑 outline → ✏️ draft → 🔬 review → 📚 final
``` ```
![Grouped status view with filtering](images/grouped-status-filtering.png) ## Data format
_Alt: Grouped status view showing notes organized by status with template filtering and search functionality_ Statuses in YAML(as string or array, it depends of your settings):
### 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
```
![Custom statuses](images/custom-status-change.png)
_Alt: Status selection modal showing custom and template statuses with icons, names, and selection interface_
## 📋 Data Format
Statuses are stored in YAML frontmatter:
```yaml ```yaml
--- ---
@ -153,49 +152,13 @@ obsidian-note-status:
--- ---
``` ```
Works with: Works with Dataview, Templater, QuickAdd, and search. Non-Markdown uses the JSON store noted above.
- **Dataview queries** ## Support the project
- **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:
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/aleixsoler) [![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/aleixsoler)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](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)

View file

@ -4,11 +4,16 @@ import {
StatusSelectorGroup, StatusSelectorGroup,
Props as StatusSelectorGroupProps, Props as StatusSelectorGroupProps,
} from "./StatusSelectorGroup"; } from "./StatusSelectorGroup";
import { StatusTemplate } from "@/types/pluginSettings";
export interface Props { export interface Props {
currentStatuses: GroupedStatuses; currentStatuses: GroupedStatuses;
filesQuantity: number; filesQuantity: number;
availableStatuses: NoteStatus[]; availableStatuses: NoteStatus[];
templates: StatusTemplate[];
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
activeStatus?: NoteStatus;
onRemoveStatus: ( onRemoveStatus: (
frontmatterTagName: string, frontmatterTagName: string,
status: NoteStatus, status: NoteStatus,
@ -23,6 +28,10 @@ export const ChangeStatusModal: React.FC<Props> = ({
currentStatuses: initialStatuses, currentStatuses: initialStatuses,
filesQuantity, filesQuantity,
availableStatuses, availableStatuses,
templates,
iconFrameMode = "never",
iconColorMode = "status",
activeStatus,
onRemoveStatus, onRemoveStatus,
onSelectStatus, onSelectStatus,
}) => { }) => {
@ -53,6 +62,10 @@ export const ChangeStatusModal: React.FC<Props> = ({
frontmatterTagName={frontmatterTagName} frontmatterTagName={frontmatterTagName}
availableStatuses={availableStatuses} availableStatuses={availableStatuses}
currentStatuses={statusList} currentStatuses={statusList}
templates={templates}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
activeStatus={activeStatus}
onSelectedState={handleSelectedState} onSelectedState={handleSelectedState}
/> />
))} ))}

View file

@ -14,16 +14,7 @@ export const CurrentStatusChips: React.FC<Props> = ({
}) => { }) => {
return ( return (
<SettingItem name="Current statuses" vertical> <SettingItem name="Current statuses" vertical>
<div <div className="note-status-chips">
className="note-status-chips"
style={{
display: "flex",
flexWrap: "wrap",
gap: "6px",
minHeight: "32px",
alignItems: "center",
}}
>
{currentStatuses.map((status) => ( {currentStatuses.map((status) => (
<StatusDisplay <StatusDisplay
key={`${status.templateId || "custom"}:${status.name}`} key={`${status.templateId || "custom"}:${status.name}`}

View file

@ -1,15 +1,20 @@
import React, { useCallback } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { SearchFilter } from "../atoms/SearchFilter"; import { SearchFilter } from "../atoms/SearchFilter";
import { StatusSelector } from "../atoms/StatusSelector"; import { StatusSelector } from "../atoms/StatusSelector";
import { SettingItem } from "../SettingsUI/SettingItem"; import { SettingItem } from "../SettingsUI/SettingItem";
import { CurrentStatusChips } from "./CurrentStatusChips"; import { CurrentStatusChips } from "./CurrentStatusChips";
import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { useKeyboardNavigation } from "./useKeyboardNavigation";
import { StatusTemplate } from "@/types/pluginSettings";
export interface Props { export interface Props {
frontmatterTagName: string; frontmatterTagName: string;
currentStatuses: NoteStatus[]; currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[]; availableStatuses: NoteStatus[];
templates: StatusTemplate[];
iconFrameMode: "always" | "never";
iconColorMode: "status" | "theme";
activeStatus?: NoteStatus;
onSelectedState: ( onSelectedState: (
frontmatterTagName: string, frontmatterTagName: string,
status: NoteStatus, status: NoteStatus,
@ -20,9 +25,16 @@ export interface Props {
export const StatusSelectorGroup: React.FC<Props> = ({ export const StatusSelectorGroup: React.FC<Props> = ({
currentStatuses, currentStatuses,
availableStatuses, availableStatuses,
templates,
frontmatterTagName, frontmatterTagName,
iconFrameMode,
iconColorMode,
activeStatus,
onSelectedState, onSelectedState,
}) => { }) => {
const TEMPLATE_ALL_VIEW = "__all__";
const TEMPLATE_CUSTOM_VIEW = "__custom__";
const handleRemoveStatus = useCallback( const handleRemoveStatus = useCallback(
(status: NoteStatus) => { (status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "unselected"); onSelectedState(frontmatterTagName, status, "unselected");
@ -37,6 +49,105 @@ export const StatusSelectorGroup: React.FC<Props> = ({
[onSelectedState, frontmatterTagName], [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 { const {
focusedIndex, focusedIndex,
searchFilter, searchFilter,
@ -46,7 +157,7 @@ export const StatusSelectorGroup: React.FC<Props> = ({
handleKeyDown, handleKeyDown,
setSearchFilter, setSearchFilter,
} = useKeyboardNavigation({ } = useKeyboardNavigation({
availableStatuses, availableStatuses: statusesForView,
currentStatuses, currentStatuses,
onSelectStatus: handleSelectStatus, onSelectStatus: handleSelectStatus,
onRemoveStatus: handleRemoveStatus, onRemoveStatus: handleRemoveStatus,
@ -57,8 +168,33 @@ export const StatusSelectorGroup: React.FC<Props> = ({
ref={containerRef} ref={containerRef}
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ outline: "none" }} className="status-selector-group"
> >
{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 <SearchFilter
ref={searchRef} ref={searchRef}
value={searchFilter} value={searchFilter}
@ -67,14 +203,7 @@ export const StatusSelectorGroup: React.FC<Props> = ({
<SettingItem name="Current statuses" vertical> <SettingItem name="Current statuses" vertical>
{filteredStatuses.length === 0 ? ( {filteredStatuses.length === 0 ? (
<div <div className="status-selector-empty">
style={{
padding: "16px",
textAlign: "center",
color: "var(--text-muted)",
fontStyle: "italic",
}}
>
{searchFilter {searchFilter
? `No statuses match "${searchFilter}"` ? `No statuses match "${searchFilter}"`
: "No statuses found"} : "No statuses found"}
@ -84,6 +213,8 @@ export const StatusSelectorGroup: React.FC<Props> = ({
availableStatuses={filteredStatuses} availableStatuses={filteredStatuses}
currentStatuses={currentStatuses} currentStatuses={currentStatuses}
focusedIndex={focusedIndex} focusedIndex={focusedIndex}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
onToggleStatus={(status, selected) => onToggleStatus={(status, selected) =>
selected selected
? handleSelectStatus(status) ? handleSelectStatus(status)

View file

@ -1,24 +1,41 @@
import { StatusIconPreview } from "@/components/atoms/StatusIconPreview";
import { GroupedStatuses } from "@/types/noteStatus"; import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC, memo } from "react"; import React, { FC, memo } from "react";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
type Props = { type Props = {
statuses: GroupedStatuses; statuses: GroupedStatuses;
onMouseEnter: (statuses: GroupedStatuses) => void; defaultTagName: string;
onMouseLeave: (statuses: GroupedStatuses) => void; onMouseEnter: (payload: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => void;
onMouseLeave: () => void;
hideUnknownStatus?: boolean; hideUnknownStatus?: boolean;
unknownStatusConfig?: { unknownStatusConfig?: {
icon: string; icon: string;
lucideIcon?: string;
color: string; color: string;
}; };
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
}; };
export const FileExplorerIcon: FC<Props> = memo( export const FileExplorerIcon: FC<Props> = memo(
({ ({
statuses, statuses,
defaultTagName,
onMouseLeave, onMouseLeave,
onMouseEnter, onMouseEnter,
hideUnknownStatus, hideUnknownStatus,
unknownStatusConfig, unknownStatusConfig,
iconFrameMode = "never",
iconColorMode = "status",
}) => { }) => {
const statusEntries = Object.entries(statuses); const statusEntries = Object.entries(statuses);
const totalStatuses = statusEntries.reduce( const totalStatuses = statusEntries.reduce(
@ -26,54 +43,58 @@ export const FileExplorerIcon: FC<Props> = memo(
0, 0,
); );
const useStatusColors = iconColorMode === "status";
if (totalStatuses === 0) { if (totalStatuses === 0) {
// If hideUnknownStatus is enabled, don't show anything for files without status
if (hideUnknownStatus) return null; if (hideUnknownStatus) return null;
// Use config passed from integration, with fallbacks
const icon = unknownStatusConfig?.icon || "❓"; const icon = unknownStatusConfig?.icon || "❓";
const color = unknownStatusConfig?.color || "#8b949e"; const color = useStatusColors
? unknownStatusConfig?.color?.trim() || getUnknownStatusColor()
: undefined;
return ( return (
<div className="status-wrapper"> <StatusIconPreview
<div icon={
className="status-minimal status-minimal--no-status" <StatusIcon
onMouseEnter={() => onMouseEnter({})} icon={icon}
onMouseLeave={() => onMouseLeave({})} lucideIcon={unknownStatusConfig?.lucideIcon}
style={ size={16}
{ />
"--primary-color": color, }
} as React.CSSProperties color={color}
} iconColorMode={iconColorMode}
> iconFrameMode={iconFrameMode}
<span className="status-icon">{icon}</span> onMouseEnter={() =>
</div> onMouseEnter({ statuses: {}, defaultTagName })
</div> }
onMouseLeave={onMouseLeave}
/>
); );
} }
const primaryStatus = statusEntries[0]?.[1]?.[0]; const primaryStatus = getPrimaryStatus(statuses);
if (!primaryStatus) return null; if (!primaryStatus) return null;
const iconColor = useStatusColors
? resolveStatusColor(primaryStatus)
: undefined;
return ( return (
<div className="status-wrapper"> <StatusIconPreview
<div icon={
className="status-minimal" <StatusIcon
onMouseEnter={() => onMouseEnter(statuses)} icon={primaryStatus.icon}
onMouseLeave={() => onMouseLeave(statuses)} lucideIcon={primaryStatus.lucideIcon}
style={ size={16}
{ />
"--primary-color": }
primaryStatus.color || "var(--text-accent)", color={iconColor}
} as React.CSSProperties count={totalStatuses}
} iconFrameMode={iconFrameMode}
> iconColorMode={iconColorMode}
<span className="status-icon">{primaryStatus.icon}</span> onMouseEnter={() => onMouseEnter({ statuses, defaultTagName })}
{totalStatuses > 1 && ( onMouseLeave={onMouseLeave}
<span className="status-count">{totalStatuses}</span> />
)}
</div>
</div>
); );
}, },
); );

View file

@ -18,6 +18,7 @@ export type StatusItem = {
name: string; name: string;
color: string; color: string;
icon?: string; icon?: string;
lucideIcon?: string;
}; };
export type FilesByStatus = { export type FilesByStatus = {
@ -69,6 +70,7 @@ const GroupedStatusViewContent = () => {
name: noteStatus.name, name: noteStatus.name,
color: noteStatus.color || "white", color: noteStatus.color || "white",
icon: noteStatus.icon, icon: noteStatus.icon,
lucideIcon: noteStatus.lucideIcon,
}; };
const scopedIdentifier = noteStatus.templateId const scopedIdentifier = noteStatus.templateId

View file

@ -1,7 +1,5 @@
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { FileItem } from "../GroupedStatusView"; import { FileItem } from "../GroupedStatusView";
import { SelectableListItem } from "../../atoms/SelectableListItem";
interface FileListProps { interface FileListProps {
files: FileItem[]; files: FileItem[];
groupKey: string; groupKey: string;
@ -48,21 +46,23 @@ export const FileList = ({
<div className="grouped-status-files"> <div className="grouped-status-files">
<div className="grouped-status-files-list" onScroll={handleScroll}> <div className="grouped-status-files-list" onScroll={handleScroll}>
{visibleFiles.map((file) => ( {visibleFiles.map((file) => (
<SelectableListItem <div
key={file.id} key={file.id}
className="tree-item nav-file grouped-status-file-item"
onClick={() => handleFileClick(file)} onClick={() => handleFileClick(file)}
className="grouped-status-file-item"
title={file.path} title={file.path}
> >
<div> <div className="tree-item-self is-clickable nav-file-title">
<span className="grouped-status-file-name"> <div className="tree-item-inner nav-file-title-content grouped-status-file-content">
{file.name} <div className="grouped-status-file-name">
</span> {file.name}
<span className="grouped-status-file-path"> </div>
{file.path} <div className="grouped-status-file-path">
</span> {file.path}
</div>
</div>
</div> </div>
</SelectableListItem> </div>
))} ))}
{hasMoreItems && ( {hasMoreItems && (

View file

@ -46,15 +46,19 @@ export const StatusGroup = ({
}, [onToggle]); }, [onToggle]);
return ( return (
<div className="grouped-status-group"> <div className="grouped-status-group tree-item">
<div <div
className="grouped-status-group__header" className="grouped-status-group__header tree-item-self is-clickable"
onClick={handleToggle} onClick={handleToggle}
> >
<div className="grouped-status-group__status"> <div className="tree-item-inner grouped-status-group__status">
<StatusDisplay <StatusDisplay
status={ status={
{ ...status, icon: status.icon || "" } as NoteStatus {
...status,
icon: status.icon || "",
lucideIcon: status.lucideIcon,
} as NoteStatus
} }
variant="badge" variant="badge"
/> />
@ -74,14 +78,16 @@ export const StatusGroup = ({
</div> </div>
{isExpanded && ( {isExpanded && (
<FileList <div className="tree-item-children">
files={files} <FileList
groupKey={groupKey} files={files}
loadedCount={loadedCount} groupKey={groupKey}
onFileClick={onFileClick} loadedCount={loadedCount}
onScroll={onScroll} onFileClick={onFileClick}
onLoadMore={onLoadMore} onScroll={onScroll}
/> onLoadMore={onLoadMore}
/>
</div>
)} )}
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import React, { useCallback } from "react"; import React, { useCallback, useMemo } from "react";
import { GroupLabel } from "@/components/atoms/GroupLabel"; import { GroupLabel } from "@/components/atoms/GroupLabel";
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter"; import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView"; import { FilesByStatus, FileItem, StatusItem } from "../GroupedStatusView";
@ -51,6 +51,11 @@ export const TagSection = ({
[onToggleFiles], [onToggleFiles],
); );
const statusOrder = useMemo(
() => Array.from(statusMap.keys()),
[statusMap],
);
return ( return (
<div className="grouped-status-tag-section"> <div className="grouped-status-tag-section">
<div className="grouped-status-tag-header" onClick={handleToggle}> <div className="grouped-status-tag-header" onClick={handleToggle}>
@ -64,32 +69,38 @@ export const TagSection = ({
{isExpanded && ( {isExpanded && (
<div className="grouped-status-tag-content"> <div className="grouped-status-tag-content">
{Object.entries(statusGroups).map(([statusName, files]) => { {Object.entries(statusGroups)
if (files.length === 0) return null; .sort(
([statusA], [statusB]) =>
statusOrder.indexOf(statusA) -
statusOrder.indexOf(statusB),
)
.map(([statusName, files]) => {
if (files.length === 0) return null;
const status = statusMap.get(statusName); const status = statusMap.get(statusName);
if (!status) return null; if (!status) return null;
const groupKey = `${tag}-${statusName}`; const groupKey = `${tag}-${statusName}`;
const filesExpanded = expandedFiles.has(groupKey); const filesExpanded = expandedFiles.has(groupKey);
const loadedCount = getLoadedCount(groupKey); const loadedCount = getLoadedCount(groupKey);
return ( return (
<StatusGroup <StatusGroup
key={statusName} key={statusName}
statusName={statusName} statusName={statusName}
status={status} status={status}
files={files} files={files}
tag={tag} tag={tag}
isExpanded={filesExpanded} isExpanded={filesExpanded}
loadedCount={loadedCount} loadedCount={loadedCount}
onToggle={() => handleToggleFiles(groupKey)} onToggle={() => handleToggleFiles(groupKey)}
onFileClick={onFileClick} onFileClick={onFileClick}
onScroll={onScroll} onScroll={onScroll}
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
/> />
); );
})} })}
</div> </div>
)} )}
</div> </div>

View file

@ -1,6 +1,8 @@
import { PluginSettings } from "@/types/pluginSettings"; import { PluginSettings } from "@/types/pluginSettings";
import React from "react"; import React, { useMemo } from "react";
import { SettingItem } from "./SettingItem"; import { SettingItem } from "./SettingItem";
import { FrontmatterMappingsSettings } from "./FrontmatterMappingsSettings";
import { BaseNoteStatusService } from "@/core/noteStatusService";
export type Props = { export type Props = {
settings: PluginSettings; settings: PluginSettings;
@ -8,10 +10,51 @@ export type Props = {
}; };
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => { export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
const availableStatuses = useMemo(() => {
return BaseNoteStatusService.getAllAvailableStatuses();
}, [
settings.templates,
settings.customStatuses,
settings.enabledTemplates,
]);
return ( return (
<div> <div>
<h3>Status tag</h3> <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 <SettingItem
name="Enable multiple statuses" name="Enable multiple statuses"
description="Allow notes to have multiple statuses at the same time" description="Allow notes to have multiple statuses at the same time"
@ -25,6 +68,44 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
/> />
</SettingItem> </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 <SettingItem
name="Status tag prefix" name="Status tag prefix"
description="YAML frontmatter tag name for status (default: obsidian-note-status)" description="YAML frontmatter tag name for status (default: obsidian-note-status)"
@ -53,6 +134,30 @@ export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
/> />
</SettingItem> </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 <SettingItem
name="Vault size limit" 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." description="Disable dashboard and grouped view for vaults with more notes than this limit to improve performance. Set to 0 to disable this feature."

View file

@ -1,13 +1,15 @@
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import React from "react"; import React from "react";
import { Input } from "@/components/atoms/Input"; import { Input } from "@/components/atoms/Input";
import { StatusIcon } from "@/components/atoms/StatusIcon";
import { IconSelectionField } from "./IconSelectionField";
export type Props = { export type Props = {
status: NoteStatus; status: NoteStatus;
index: number; index: number;
onCustomStatusChange: ( onCustomStatusChange: (
index: number, index: number,
column: "name" | "icon" | "color" | "description", column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string, value: string,
) => void; ) => void;
onCustomStatusRemove: (index: number) => void; onCustomStatusRemove: (index: number) => void;
@ -28,26 +30,28 @@ export const CustomStatusItem: React.FC<Props> = ({
canMoveDown = false, canMoveDown = false,
}) => { }) => {
const isValid = status.name.trim().length > 0; const isValid = status.name.trim().length > 0;
const displayIcon = status.icon.trim() || "📝";
return ( return (
<div className="custom-status-item"> <div className="custom-status-item">
{/* Simple horizontal layout with all inputs in a row */} <IconSelectionField
<div className="custom-status-item__row"> className="custom-status-item__icon-fields"
{/* Icon field - simple text input */} emojiValue={status.icon}
<div className="custom-status-item__field"> onEmojiChange={(value) =>
<label className="custom-status-item__label">Icon</label> onCustomStatusChange(index, "icon", value || "")
<Input }
variant="text" emojiLabel="Emoji icon"
value={status.icon} emojiPlaceholder="Example: ✅ or 🚧"
onChange={(value) => emojiHint="Shown anywhere Lucide is not available."
onCustomStatusChange(index, "icon", value || "") lucideValue={status.lucideIcon || ""}
} onLucideChange={(value) =>
placeholder="📝" onCustomStatusChange(index, "lucideIcon", value)
className="custom-status-item__input custom-status-item__input--icon" }
/> lucideLabel="Lucide icon (optional)"
</div> lucidePlaceholder="Browse Lucide icons"
lucideHint="Matches Obsidian's toolbar icons so your status button blends in."
/>
{/* Simple horizontal layout with remaining inputs */}
<div className="custom-status-item__row">
{/* Name field */} {/* Name field */}
<div className="custom-status-item__field custom-status-item__field--name"> <div className="custom-status-item__field custom-status-item__field--name">
<label className="custom-status-item__label"> <label className="custom-status-item__label">
@ -139,12 +143,13 @@ export const CustomStatusItem: React.FC<Props> = ({
{/* Preview row - shows how the status will look */} {/* Preview row - shows how the status will look */}
<div className="custom-status-item__preview"> <div className="custom-status-item__preview">
<span <StatusIcon
icon={status.icon}
lucideIcon={status.lucideIcon}
className="custom-status-item__preview-icon" className="custom-status-item__preview-icon"
size={18}
style={{ color: status.color }} style={{ color: status.color }}
> />
{displayIcon}
</span>
<span <span
className="custom-status-item__preview-text" className="custom-status-item__preview-text"
style={{ color: status.color }} style={{ color: status.color }}

View file

@ -16,17 +16,18 @@ export const CustomStatusSettings: React.FC<Props> = ({
onChange, onChange,
}) => { }) => {
const addNewCustomStatus = () => { const addNewCustomStatus = () => {
const currentStatuses = [...settings.customStatuses]; const currentStatuses = [...(settings.customStatuses || [])];
currentStatuses.push({ currentStatuses.push({
name: "", name: "",
icon: "", icon: "",
lucideIcon: "",
}); });
onChange("customStatuses", currentStatuses); onChange("customStatuses", currentStatuses);
}; };
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = ( const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
index, index,
) => { ) => {
const currentStatuses = [...settings.customStatuses]; const currentStatuses = [...(settings.customStatuses || [])];
currentStatuses.splice(index, 1); currentStatuses.splice(index, 1);
onChange("customStatuses", currentStatuses); onChange("customStatuses", currentStatuses);
}; };
@ -36,7 +37,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
column, column,
value, value,
) => { ) => {
const currentStatuses = [...settings.customStatuses]; const currentStatuses = [...(settings.customStatuses || [])];
const target = currentStatuses[index]; const target = currentStatuses[index];
if (target) { if (target) {
@ -47,7 +48,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
const moveCustomStatusUp = (index: number) => { const moveCustomStatusUp = (index: number) => {
if (index <= 0) return; if (index <= 0) return;
const currentStatuses = [...settings.customStatuses]; const currentStatuses = [...(settings.customStatuses || [])];
[currentStatuses[index - 1], currentStatuses[index]] = [ [currentStatuses[index - 1], currentStatuses[index]] = [
currentStatuses[index], currentStatuses[index],
currentStatuses[index - 1], currentStatuses[index - 1],
@ -56,8 +57,8 @@ export const CustomStatusSettings: React.FC<Props> = ({
}; };
const moveCustomStatusDown = (index: number) => { const moveCustomStatusDown = (index: number) => {
if (index >= settings.customStatuses.length - 1) return; const currentStatuses = [...(settings.customStatuses || [])];
const currentStatuses = [...settings.customStatuses]; if (index >= currentStatuses.length - 1) return;
[currentStatuses[index], currentStatuses[index + 1]] = [ [currentStatuses[index], currentStatuses[index + 1]] = [
currentStatuses[index + 1], currentStatuses[index + 1],
currentStatuses[index], currentStatuses[index],
@ -84,11 +85,11 @@ export const CustomStatusSettings: React.FC<Props> = ({
<SettingItem <SettingItem
name="Custom statuses" name="Custom statuses"
description="Create custom statuses with icons, names, and colors. All statuses require a name." description="Create custom statuses with emoji icons and optional Lucide icon names plus colors. All statuses require a name."
vertical vertical
> >
<div className="custom-status-list"> <div className="custom-status-list">
{settings.customStatuses.length === 0 ? ( {(settings.customStatuses || []).length === 0 ? (
<div className="custom-status-list__empty"> <div className="custom-status-list__empty">
<p> <p>
No custom statuses yet. Click "Add Status" below No custom statuses yet. Click "Add Status" below
@ -96,7 +97,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
</p> </p>
</div> </div>
) : ( ) : (
settings.customStatuses.map((status, index) => ( (settings.customStatuses || []).map((status, index) => (
<CustomStatusItem <CustomStatusItem
key={index} key={index}
status={status} status={status}
@ -107,7 +108,8 @@ export const CustomStatusSettings: React.FC<Props> = ({
onMoveDown={moveCustomStatusDown} onMoveDown={moveCustomStatusDown}
canMoveUp={index > 0} canMoveUp={index > 0}
canMoveDown={ canMoveDown={
index < settings.customStatuses.length - 1 index <
(settings.customStatuses || []).length - 1
} }
/> />
)) ))

View file

@ -0,0 +1,310 @@
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>
);
};

View file

@ -0,0 +1,75 @@
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>
);
};

View file

@ -0,0 +1,223 @@
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();
}
}

View file

@ -0,0 +1,79 @@
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>
);
};

View file

@ -0,0 +1,208 @@
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>
);
};

View file

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

View file

@ -41,7 +41,12 @@ export const QuickCommandsSettings: React.FC<Props> = ({
} }
} }
return { customStatuses, templateGroups }; return { customStatuses, templateGroups };
}, [settings]); }, [
settings.useCustomStatusesOnly,
settings.customStatuses,
settings.enabledTemplates,
settings.templates,
]);
const currentQuickCommands = settings.quickStatusCommands || []; const currentQuickCommands = settings.quickStatusCommands || [];

View file

@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { PluginSettings } from "@/types/pluginSettings"; import { PluginSettings } from "@/types/pluginSettings";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type StatusGroupProps = { export type StatusGroupProps = {
statuses: PluginSettings["customStatuses"]; statuses: PluginSettings["customStatuses"];
@ -39,9 +40,12 @@ export const StatusGroup: React.FC<StatusGroupProps> = ({
> >
<div className="status-selector__content"> <div className="status-selector__content">
<div className="status-selector__status"> <div className="status-selector__status">
<span className="status-selector__icon"> <StatusIcon
{status.icon} icon={status.icon}
</span> lucideIcon={status.lucideIcon}
className="status-selector__icon"
size={16}
/>
<span className="status-selector__name"> <span className="status-selector__name">
{status.name} {status.name}
</span> </span>

View file

@ -0,0 +1,271 @@
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>
);
};

View file

@ -1,9 +1,11 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useMemo } from "react";
import { StatusTemplate } from "@/types/pluginSettings"; import { StatusTemplate } from "@/types/pluginSettings";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { Input } from "@/components/atoms/Input"; import { Input } from "@/components/atoms/Input";
import { SettingItem } from "./SettingItem"; import { SettingItem } from "./SettingItem";
import { CustomStatusItem } from "./CustomStatusItem"; import { CustomStatusItem } from "./CustomStatusItem";
import { isCustomTemplate } from "@/utils/templateUtils";
import settingsService from "@/core/settingsService";
interface TemplateEditorModalProps { interface TemplateEditorModalProps {
template?: StatusTemplate; template?: StatusTemplate;
@ -16,13 +18,27 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
onSave, onSave,
onCancel, onCancel,
}) => { }) => {
const isCustom = useMemo(
() => (template ? isCustomTemplate(template) : true),
[template],
);
const [name, setName] = useState(template?.name || ""); 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 [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[]>( const [statuses, setStatuses] = useState<NoteStatus[]>(
template?.statuses || [ template?.statuses || [
{ {
name: "", name: "",
icon: "", icon: "",
lucideIcon: "",
color: "#888888", color: "#888888",
templateId: template?.id || "", templateId: template?.id || "",
}, },
@ -30,15 +46,24 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
); );
const isEditing = !!template; const isEditing = !!template;
const isIdUnique =
isEditing && id === template?.id
? true
: !settingsService.settings.templates.some(
(t) => t.id === id.trim(),
);
const isValid = const isValid =
name.trim().length > 0 && name.trim().length > 0 &&
id.trim().length > 0 &&
isIdUnique &&
description.trim().length > 0 && description.trim().length > 0 &&
statuses.some((s) => s.name.trim().length > 0); statuses.some((s) => s.name.trim().length > 0);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
if (!isValid) return; if (!isValid) return;
const templateId = template?.id || `custom-${Date.now()}`; const templateId = id.trim();
const validStatuses = statuses.filter((s) => s.name.trim().length > 0); const validStatuses = statuses.filter((s) => s.name.trim().length > 0);
// Update templateId for all statuses // Update templateId for all statuses
@ -51,11 +76,39 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
id: templateId, id: templateId,
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
language: language.trim() || undefined,
type: type.trim() || undefined,
authorGithub: authorGithub.trim() || undefined,
statuses: statusesWithTemplateId, statuses: statusesWithTemplateId,
}; };
onSave(savedTemplate); onSave(savedTemplate);
}, [name, description, statuses, template, isValid, onSave]); }, [
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);
}, []);
const handleAddStatus = useCallback(() => { const handleAddStatus = useCallback(() => {
setStatuses((prev) => [ setStatuses((prev) => [
@ -63,6 +116,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
{ {
name: "", name: "",
icon: "", icon: "",
lucideIcon: "",
color: "#888888", color: "#888888",
templateId: template?.id || "", templateId: template?.id || "",
}, },
@ -72,7 +126,7 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
const handleStatusChange = useCallback( const handleStatusChange = useCallback(
( (
index: number, index: number,
column: "name" | "icon" | "color" | "description", column: "name" | "icon" | "color" | "description" | "lucideIcon",
value: string, value: string,
) => { ) => {
setStatuses((prev) => setStatuses((prev) =>
@ -126,11 +180,46 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
<Input <Input
variant="text" variant="text"
value={name} value={name}
onChange={setName} onChange={handleNameChange}
placeholder="e.g. Project Workflow" placeholder="e.g. Project Workflow"
/> />
</SettingItem> </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 <SettingItem
name="Description" name="Description"
description="Brief description of the template's purpose" description="Brief description of the template's purpose"
@ -143,9 +232,64 @@ export const TemplateEditorModal: React.FC<TemplateEditorModalProps> = ({
/> />
</SettingItem> </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 <SettingItem
name="Statuses" name="Statuses"
description="Define the statuses included in this template" description="Define the statuses included in this template, including emojis and optional Lucide icons"
vertical vertical
> >
<div className="template-editor-statuses"> <div className="template-editor-statuses">

View file

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

View file

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

View file

@ -1,152 +1,61 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react"; import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { Input } from "../atoms/Input"; import { Input } from "../atoms/Input";
import { Select } from "../atoms/Select"; import { Select } from "../atoms/Select";
import { SettingItem } from "./SettingItem"; import { SettingItem } from "./SettingItem";
import { IconSelectionField } from "./IconSelectionField";
export type Props = { export type Props = {
settings: PluginSettings; settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void; onChange: (key: keyof PluginSettings, value: unknown) => void;
}; };
export const UISettings: React.FC<Props> = ({ settings, onChange }) => { export const StatusBarSettings: React.FC<Props> = ({ settings, onChange }) => {
const handleChange = const handleCheckbox =
(key: keyof PluginSettings) => (key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) =>
onChange(key, e.target.checked); onChange(key, e.target.checked);
};
return ( return (
<div className="ui-settings"> <div className="ui-settings__group">
<h3>User interface</h3> <h3>Status Bar</h3>
<SettingItem <SettingItem
name="Show status bar" name="Show status icon in status bar"
description="Display the status bar" description="Display a status icon in the status bar with the current note status."
> >
<input <input
type="checkbox" type="checkbox"
checked={settings.showStatusBar} checked={settings.showStatusBar}
onChange={handleChange("showStatusBar")} onChange={handleCheckbox("showStatusBar")}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
name="Auto-hide status bar" name="Hide icon when note has no status"
description="Hide the status bar when status is unknown" description="Automatically hide the status icon whenever the current note does not have a status."
> >
<input <input
type="checkbox" type="checkbox"
checked={settings.autoHideStatusBar} checked={settings.autoHideStatusBar}
onChange={handleChange("autoHideStatusBar")} onChange={handleCheckbox("autoHideStatusBar")}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
name="Show status icons in file explorer" name="Show status overview popup"
description="Display status icons in the file explorer" description="Show a popup listing statuses when hovering or interacting with the status icon."
> >
<input <input
type="checkbox" type="checkbox"
checked={settings.showStatusIconsInExplorer} checked={settings.enableStatusOverviewPopup ?? true}
onChange={handleChange("showStatusIconsInExplorer")} onChange={handleCheckbox("enableStatusOverviewPopup")}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
name="Status icon in file explorer position" name="Show template names next to status"
description="Choose icon position in file explorer" description="Control how the template name is shown alongside the current status."
>
<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 <Select
options={[ options={[
@ -170,29 +79,117 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
/> />
</SettingItem> </SettingItem>
<h4>Editor Toolbar Button</h4> <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>
<SettingItem <SettingItem
name="Show editor toolbar button" name="Show editor toolbar button"
description="Display status button in the editor toolbar" description="Add a button to the editor toolbar to change the note status."
> >
<input <input
type="checkbox" type="checkbox"
checked={settings.showEditorToolbarButton ?? true} checked={settings.showEditorToolbarButton ?? true}
onChange={handleChange("showEditorToolbarButton")} onChange={handleCheckbox("showEditorToolbarButton")}
/> />
</SettingItem> </SettingItem>
<SettingItem <SettingItem
name="Editor toolbar button position" name="Editor toolbar button position"
description="Choose where to position the status button in the editor toolbar" description="Choose where the status button should appear in the editor toolbar."
> >
<Select <Select
options={[ options={[
{ { value: "left", display: "Left side of toolbar" },
value: "left",
display: "Left side of toolbar",
},
{ {
value: "right", value: "right",
display: "Right side (after all buttons)", display: "Right side (after all buttons)",
@ -212,19 +209,13 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
</SettingItem> </SettingItem>
<SettingItem <SettingItem
name="Editor toolbar button display" name="Show toolbar button for"
description="Control which notes show the status button" description="Control which editors should display the toolbar button."
> >
<Select <Select
options={[ options={[
{ { value: "all-notes", display: "All open notes" },
value: "all-notes", { value: "active-only", display: "Active note only" },
display: "All open notes",
},
{
value: "active-only",
display: "Active note only",
},
]} ]}
defaultValue={ defaultValue={
settings.editorToolbarButtonDisplay || "all-notes" settings.editorToolbarButtonDisplay || "all-notes"
@ -237,3 +228,243 @@ export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
</div> </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>
);
};

View file

@ -1,18 +1,47 @@
import { useState, useEffect } from "react"; import React, { useEffect, useState } from "react";
import { Notice } from "obsidian";
import { PluginSettings } from "@/types/pluginSettings"; import { PluginSettings } from "@/types/pluginSettings";
import { UISettings } from "./UISettings";
import { TemplateSettings } from "./TemplateSettings"; import { TemplateSettings } from "./TemplateSettings";
import { BehaviourSettings } from "./BehaviourSettings"; import { BehaviourSettings } from "./BehaviourSettings";
import { CustomStatusSettings } from "./CustomStatusSettings"; import { CustomStatusSettings } from "./CustomStatusSettings";
import { QuickCommandsSettings } from "./QuickCommandsSettings"; import { QuickCommandsSettings } from "./QuickCommandsSettings";
import { SynchronizationSettings } from "./SynchronizationSettings";
import {
EditorToolbarSettings,
ExperimentalSettings,
FileExplorerSettings,
StatusBarSettings,
UnknownStatusAppearanceSettings,
} from "./UISettings";
export type Props = { export type Props = {
settings: PluginSettings; settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void; 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 SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
const [localSettings, setLocalSettings] = useState(settings); const [localSettings, setLocalSettings] = useState(settings);
const [expandedSections, setExpandedSections] = useState<
Partial<Record<SectionId, boolean>>
>({});
useEffect(() => { useEffect(() => {
setLocalSettings(settings); setLocalSettings(settings);
@ -23,16 +52,239 @@ const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
onChange(key, value); 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 ( return (
<div className="note-status-settings"> <div className="note-status-settings">
<TemplateSettings settings={settings} onChange={handleChange} /> {sections.map((section) => {
<UISettings settings={localSettings} onChange={handleChange} /> const isOpen = expandedSections[section.id] ?? false;
<BehaviourSettings settings={settings} onChange={handleChange} /> return (
<CustomStatusSettings settings={settings} onChange={handleChange} /> <div
<QuickCommandsSettings key={section.id}
settings={settings} className={`note-status-section ${
onChange={handleChange} 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>
</div> </div>
); );
}; };

View file

@ -1,10 +1,11 @@
import { GroupedStatuses } from "@/types/noteStatus"; import { GroupedStatuses } from "@/types/noteStatus";
import { FC } from "react"; import { CSSProperties, FC } from "react";
import { import {
StatusBarProvider, StatusBarProvider,
Props as StatusBarContextProps, Props as StatusBarContextProps,
} from "./StatusBarContext"; } from "./StatusBarContext";
import { StatusBarGroup } from "./StatusBarGroup"; import { StatusBarGroup } from "./StatusBarGroup";
import { StatusIcon } from "@/components/atoms/StatusIcon";
export type Props = { export type Props = {
statuses: GroupedStatuses; statuses: GroupedStatuses;
@ -12,11 +13,14 @@ export type Props = {
hideIfNotStatuses?: boolean; hideIfNotStatuses?: boolean;
onStatusClick: StatusBarContextProps["onStatusClick"]; onStatusClick: StatusBarContextProps["onStatusClick"];
templateNameMode?: "always" | "never" | "auto"; templateNameMode?: "always" | "never" | "auto";
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
noStatusConfig?: { noStatusConfig?: {
text: string; text: string;
showIcon: boolean; showIcon: boolean;
showText: boolean; showText: boolean;
icon: string; icon: string;
lucideIcon?: string;
color: string; color: string;
}; };
}; };
@ -26,6 +30,8 @@ export const StatusBar: FC<Props> = ({
hideIfNotStatuses, hideIfNotStatuses,
onStatusClick, onStatusClick,
templateNameMode = "auto", templateNameMode = "auto",
badgeStyle = "accent",
badgeContentMode = "icon-text",
noStatusConfig, noStatusConfig,
}) => { }) => {
const statusEntries = Object.entries(statuses); const statusEntries = Object.entries(statuses);
@ -43,23 +49,29 @@ export const StatusBar: FC<Props> = ({
return ( return (
<span <span
className="status-bar-item mod-clickable" className="status-bar-item status-bar-no-status mod-clickable"
onClick={() => onStatusClick({ name: "", icon: "" })} onClick={() => onStatusClick({ name: "", icon: "" })}
style={{ style={
cursor: "pointer", noStatusConfig.color
color: noStatusConfig.color, ? ({
}} "--status-empty-color": noStatusConfig.color,
} as CSSProperties)
: undefined
}
> >
{noStatusConfig.showIcon && ( {noStatusConfig.showIcon && (
<span <StatusIcon
style={{ icon={noStatusConfig.icon}
marginRight: noStatusConfig.showText ? "4px" : "0", lucideIcon={noStatusConfig.lucideIcon}
}} size={14}
> className="status-bar-no-status__icon"
{noStatusConfig.icon} />
)}
{noStatusConfig.showText && (
<span className="status-bar-no-status__label">
{noStatusConfig.text}
</span> </span>
)} )}
{noStatusConfig.showText && noStatusConfig.text}
</span> </span>
); );
} }
@ -72,6 +84,8 @@ export const StatusBar: FC<Props> = ({
key={frontmatterTagName} key={frontmatterTagName}
statuses={statusList} statuses={statusList}
templateNameMode={templateNameMode} templateNameMode={templateNameMode}
badgeStyle={badgeStyle}
badgeContentMode={badgeContentMode}
template={{ template={{
description: "Note status", description: "Note status",
name: "", name: "",

View file

@ -1,17 +0,0 @@
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>
);
};

View file

@ -1,4 +1,4 @@
import { FC, useState } from "react"; import { CSSProperties, FC, useState } from "react";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { StatusTemplate } from "@/types/pluginSettings"; import { StatusTemplate } from "@/types/pluginSettings";
import { GroupLabel } from "../atoms/GroupLabel"; import { GroupLabel } from "../atoms/GroupLabel";
@ -11,6 +11,8 @@ export interface StatusBarGroupProps {
template: StatusTemplate; template: StatusTemplate;
maxVisible?: number; maxVisible?: number;
templateNameMode?: "always" | "never" | "auto"; templateNameMode?: "always" | "never" | "auto";
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
} }
export const StatusBarGroup: FC<StatusBarGroupProps> = ({ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
@ -18,6 +20,8 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
template, template,
maxVisible = 3, maxVisible = 3,
templateNameMode = "auto", templateNameMode = "auto",
badgeStyle = "accent",
badgeContentMode = "icon-text",
}) => { }) => {
const [isUncollapsed, setIsUncollapsed] = useState(false); const [isUncollapsed, setIsUncollapsed] = useState(false);
const visibleStatuses = isUncollapsed const visibleStatuses = isUncollapsed
@ -36,19 +40,24 @@ export const StatusBarGroup: FC<StatusBarGroupProps> = ({
<GroupLabel name={template.name} isHighlighted={isUncollapsed} /> <GroupLabel name={template.name} isHighlighted={isUncollapsed} />
<div className="status-bar-group-container"> <div className="status-bar-group-container">
{visibleStatuses.map((status, i) => ( {visibleStatuses.map((status, i) => (
// Use a wrapper so we can animate statuses when expanding
<div <div
key={i} key={i}
style={{ className={`status-bar-status-item${isUncollapsed && i >= maxVisible ? " status-bar-status-item--animated" : ""}`}
animation: style={
isUncollapsed && i >= maxVisible isUncollapsed && i >= maxVisible
? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both` ? ({
: "none", "--status-anim-delay": `${i * 0.05}s`,
}} } as CSSProperties)
: undefined
}
title={status.description} title={status.description}
> >
<StatusDisplay <StatusDisplay
status={status} status={status}
variant="badge" variant="badge"
badgeStyle={badgeStyle}
badgeContentMode={badgeContentMode}
templateNameMode={templateNameMode} templateNameMode={templateNameMode}
hasNameConflicts={getHasConflicts(status)} hasNameConflicts={getHasConflicts(status)}
onClick={() => { onClick={() => {

View file

@ -5,6 +5,7 @@ import {
BaseNoteStatusService, BaseNoteStatusService,
NoteStatusService, NoteStatusService,
} from "@/core/noteStatusService"; } from "@/core/noteStatusService";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
interface CurrentNoteInfo { interface CurrentNoteInfo {
file: TFile | null; file: TFile | null;
@ -30,9 +31,18 @@ export const useCurrentNote = () => {
const noteStatusService = new NoteStatusService(activeFile); const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses(); 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({ setCurrentNote({
file: activeFile, file: activeFile,
statuses: noteStatusService.statuses, statuses: statusesByKey,
lastModified: activeFile.stat.mtime, lastModified: activeFile.stat.mtime,
}); });
}, []); }, []);

View file

@ -1,8 +1,11 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { BaseNoteStatusService } from "@/core/noteStatusService"; import {
import settingsService from "@/core/settingsService"; BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
export interface VaultStats { export interface VaultStats {
totalNotes: number; totalNotes: number;
@ -27,10 +30,10 @@ export const useVaultStats = () => {
}); });
const calculateVaultStats = useCallback((): VaultStats => { const calculateVaultStats = useCallback((): VaultStats => {
const files = BaseNoteStatusService.app.vault.getMarkdownFiles(); const files = BaseNoteStatusService.app.vault.getFiles();
const availableStatuses = const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses(); BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix]; const statusMetadataKeys = getAllFrontmatterKeys();
let notesWithStatus = 0; let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {}; const statusDistribution: Record<string, number> = {};
@ -49,50 +52,26 @@ export const useVaultStats = () => {
}); });
files.forEach((file) => { files.forEach((file) => {
const cachedMetadata = const noteStatusService = new NoteStatusService(file);
BaseNoteStatusService.app.metadataCache.getFileCache(file); noteStatusService.populateStatuses();
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false; let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => { statusMetadataKeys.forEach((key) => {
const value = frontmatter[key]; const statuses = noteStatusService.getStatusesForKey(key);
if (value) { if (!statuses.length) return;
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value]; hasAnyStatus = true;
statusNames.forEach((statusName) => { tagDistribution[key]++;
const statusStr = statusName.toString();
// Determine the scoped identifier to use statuses.forEach((status) => {
let scopedIdentifier: string; const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
if (statusStr.includes(":")) { : status.name;
// Already scoped if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
scopedIdentifier = statusStr; statusDistribution[scopedIdentifier] = 0;
} else { }
// Legacy status - find first template that has this status statusDistribution[scopedIdentifier]++;
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) { if (hasAnyStatus) {

View file

@ -4,11 +4,17 @@ import { StatusDisplay } from "../atoms/StatusDisplay";
export interface Props { export interface Props {
statuses: GroupedStatuses; statuses: GroupedStatuses;
defaultTagName: string;
onClose?: () => void; onClose?: () => void;
} }
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => { export const StatusFileInfoPopup: React.FC<Props> = ({
const statusEntries = Object.entries(statuses); statuses,
defaultTagName,
}) => {
const statusEntries = Object.entries(statuses).filter(
([, statusList]) => statusList.length > 0,
);
if (statusEntries.length === 0) { if (statusEntries.length === 0) {
return ( return (
@ -27,40 +33,39 @@ export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
</div> </div>
<div className="status-popup-content"> <div className="status-popup-content">
{statusEntries.map(([groupName, statusList]) => ( <div className="status-tag-grid">
<div key={groupName} className="status-group"> {statusEntries.map(([groupName, statusList]) => {
<div className="status-group__header"> const isDefault = groupName === defaultTagName;
<span className="status-group__name"> return (
{groupName.toLowerCase()} <div key={groupName} className="status-tag-card">
</span> <div className="status-tag-card__header">
<span className="status-group__count"> <div className="status-tag-card__title">
{statusList.length} <span className="status-tag-card__label">
</span> {groupName}
</div> </span>
{isDefault && (
<div className="status-group__items"> <span className="status-tag-card__badge">
{statusList.map((status, index) => ( Default tag
<div </span>
key={`${groupName}-${index}`} )}
className="status-item" </div>
> <span className="status-tag-card__count">
<StatusDisplay {statusList.length}
status={status} </span>
variant="badge"
/>
{status.description && (
<div
className="status-description"
title={status.description}
>
{status.description}
</div>
)}
</div> </div>
))} <div className="status-tag-card__statuses">
</div> {statusList.map((status, index) => (
</div> <StatusDisplay
))} key={`${groupName}-${index}`}
status={status}
variant="badge"
/>
))}
</div>
</div>
);
})}
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,18 +1,21 @@
import React, { FC, memo } from "react"; import React, { FC, memo } from "react";
import { GroupedStatuses } from "@/types/noteStatus"; import { GroupedStatuses } from "@/types/noteStatus";
import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration"; import { StatusesInfoPopup } from "@/integrations/popups/statusesInfoPopupIntegration";
import { StatusIcon } from "@/components/atoms/StatusIcon";
interface EditorToolbarButtonProps { interface EditorToolbarButtonProps {
statuses: GroupedStatuses; statuses: GroupedStatuses;
defaultTagName: string;
onClick: () => void; onClick: () => void;
unknownStatusConfig: { unknownStatusConfig: {
icon: string; icon: string;
lucideIcon?: string;
color: string; color: string;
}; };
} }
export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo( export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
({ statuses, onClick, unknownStatusConfig }) => { ({ statuses, defaultTagName, onClick, unknownStatusConfig }) => {
const statusEntries = Object.entries(statuses); const statusEntries = Object.entries(statuses);
const allStatuses = statusEntries.flatMap( const allStatuses = statusEntries.flatMap(
([_, statusList]) => statusList, ([_, statusList]) => statusList,
@ -20,7 +23,7 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
const totalStatuses = allStatuses.length; const totalStatuses = allStatuses.length;
const handleMouseEnter = () => { const handleMouseEnter = () => {
StatusesInfoPopup.open(statuses); StatusesInfoPopup.open({ statuses, defaultTagName });
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
@ -40,12 +43,13 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
aria-label="Add status to note" aria-label="Add status to note"
> >
<span <StatusIcon
icon={unknownStatusConfig.icon}
lucideIcon={unknownStatusConfig.lucideIcon}
size={16}
className="editor-toolbar-button__icon" className="editor-toolbar-button__icon"
style={{ color: unknownStatusConfig.color }} style={{ color: unknownStatusConfig.color }}
> />
{unknownStatusConfig.icon}
</span>
</button> </button>
); );
} }
@ -63,16 +67,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
aria-label={`Current status: ${primaryStatus.name}. Click to change.`} aria-label={`Current status: ${primaryStatus.name}. Click to change.`}
> >
<span <StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status" className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{ style={{
color: color:
primaryStatus.color || primaryStatus.color ||
"var(--interactive-accent)", "var(--interactive-accent)",
}} }}
> />
{primaryStatus.icon || "📝"}
</span>
</button> </button>
); );
} }
@ -88,16 +93,17 @@ export const EditorToolbarButton: FC<EditorToolbarButtonProps> = memo(
aria-label={`${totalStatuses} statuses assigned. Click to change.`} aria-label={`${totalStatuses} statuses assigned. Click to change.`}
> >
<div className="editor-toolbar-button__icon-container"> <div className="editor-toolbar-button__icon-container">
<span <StatusIcon
icon={primaryStatus.icon}
lucideIcon={primaryStatus.lucideIcon}
size={16}
className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status" className="editor-toolbar-button__icon editor-toolbar-button__icon--has-status"
style={{ style={{
color: color:
primaryStatus.color || primaryStatus.color ||
"var(--interactive-accent)", "var(--interactive-accent)",
}} }}
> />
{primaryStatus.icon || "📝"}
</span>
<span className="editor-toolbar-button__counter"> <span className="editor-toolbar-button__counter">
{totalStatuses} {totalStatuses}
</span> </span>

View file

@ -12,18 +12,16 @@ export const GroupLabel: FC<GroupLabelProps> = ({
}) => { }) => {
if (!name) return null; if (!name) return null;
const className = [
"setting-item-name",
"group-label-container",
isHighlighted ? "group-label-container--highlighted" : "",
]
.filter(Boolean)
.join(" ");
return ( return (
<span <span className={className}>
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} /> <ObsidianIcon name="folder" size={16} />
{name} {name}
</span> </span>

View file

@ -11,6 +11,7 @@ interface BaseInputProps {
style?: React.CSSProperties; style?: React.CSSProperties;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
disabled?: boolean;
} }
interface TextInputProps extends BaseInputProps { interface TextInputProps extends BaseInputProps {
@ -27,37 +28,6 @@ interface SearchInputProps extends BaseInputProps {
export type InputProps = TextInputProps | ColorInputProps | SearchInputProps; 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>( export const Input = React.forwardRef<HTMLInputElement, InputProps>(
( (
{ {
@ -69,23 +39,37 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
style, style,
onFocus, onFocus,
onBlur, onBlur,
disabled,
}, },
ref, ref,
) => { ) => {
const inputStyles = getInputStyles(variant); const inputType =
const combinedStyles = { ...inputStyles, ...style }; variant === "color"
? "color"
: variant === "search"
? "search"
: "text";
const classes = [
"note-status-input",
`note-status-input--${variant}`,
variant === "search" ? "search-input" : "",
className,
]
.filter(Boolean)
.join(" ");
return ( return (
<input <input
ref={ref} ref={ref}
type={variant === "color" ? "color" : "text"} type={inputType}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className={className} className={classes}
style={combinedStyles} style={style}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled}
/> />
); );
}, },

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useState } from "react"; import React, { ReactNode } from "react";
import { ObsidianIcon } from "./ObsidianIcon"; import { ObsidianIcon } from "./ObsidianIcon";
interface SelectableListItemProps { interface SelectableListItemProps {
@ -20,8 +20,6 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
className = "", className = "",
title, title,
}) => { }) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
if (onClick) { if (onClick) {
setTimeout(() => { setTimeout(() => {
@ -30,60 +28,25 @@ export const SelectableListItem: React.FC<SelectableListItemProps> = ({
} }
}; };
const composedClassName = ["selectable-list-item", className]
.filter(Boolean)
.join(" ");
return ( return (
<div <div
className={`selectable-list-item ${className}`} className={composedClassName}
data-selected={selected}
data-focused={focused}
onClick={handleClick} onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={title} 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 && ( {icon && <span className="selectable-list-item-icon">{icon}</span>}
<span <span className="selectable-list-item-content">{children}</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 && ( {selected && (
<div <ObsidianIcon
name="check"
className="selectable-list-item-check" className="selectable-list-item-check"
style={{ />
color: "var(--interactive-accent)",
}}
>
<ObsidianIcon name="check" />
</div>
)} )}
</div> </div>
); );

View file

@ -1,16 +1,28 @@
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { FC, memo, useState } from "react"; import {
CSSProperties,
FC,
MouseEvent,
ReactNode,
memo,
useMemo,
useState,
} from "react";
import { getStatusTooltip } from "@/utils/statusUtils"; import { getStatusTooltip } from "@/utils/statusUtils";
import { ObsidianIcon } from "./ObsidianIcon"; import { ObsidianIcon } from "./ObsidianIcon";
import { StatusIcon } from "./StatusIcon";
export type StatusDisplayVariant = "chip" | "badge" | "template"; export type StatusDisplayVariant = "chip" | "badge" | "template";
interface StatusDisplayProps { interface StatusDisplayProps {
status: NoteStatus; status: NoteStatus;
variant: StatusDisplayVariant; variant: StatusDisplayVariant;
badgeStyle?: "accent" | "filled" | "dot";
badgeContentMode?: "icon-text" | "icon" | "text" | "none";
removable?: boolean; removable?: boolean;
hasNameConflicts?: boolean; hasNameConflicts?: boolean;
templateNameMode?: "always" | "never" | "auto"; templateNameMode?: "always" | "never" | "auto";
icon?: ReactNode;
onRemove?: () => void; onRemove?: () => void;
onClick?: () => void; onClick?: () => void;
} }
@ -20,8 +32,11 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
status, status,
variant, variant,
removable = false, removable = false,
badgeStyle = "accent",
badgeContentMode = "icon-text",
hasNameConflicts = false, hasNameConflicts = false,
templateNameMode = "auto", templateNameMode = "auto",
icon,
onRemove, onRemove,
onClick, onClick,
}) => { }) => {
@ -46,7 +61,7 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
: status.name; : status.name;
}; };
const handleRemove = (e: React.MouseEvent) => { const handleRemove = (e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (onRemove) { if (onRemove) {
setIsRemoving(true); setIsRemoving(true);
@ -62,71 +77,102 @@ 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") { if (variant === "chip") {
const chipClassName = [
"note-status-chip",
isRemoving ? "note-status-chip--removing" : "",
onClick ? "note-status-chip--clickable" : "",
]
.filter(Boolean)
.join(" ");
return ( return (
<div <div
className="note-status-chip" className={chipClassName}
title={getStatusTooltip(status)} title={getStatusTooltip(status)}
style={{ data-clickable={!!onClick}
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} onClick={handleClick}
> >
<span className="note-status-chip-icon"> <span className="note-status-chip__icon">{iconNode}</span>
{status.icon ? status.icon : "📝"} <span className="note-status-chip__text">
</span>
<span className="note-status-chip-text">
{getDisplayName()} {getDisplayName()}
</span> </span>
{removable && ( {removable && (
<div <button
className="note-status-chip-remove" type="button"
className="note-status-chip__remove"
onClick={handleRemove} onClick={handleRemove}
style={{ aria-label={`Remove status ${status.name}`}
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} /> <ObsidianIcon name="trash" size={12} />
</div> </button>
)} )}
</div> </div>
); );
} }
if (variant === "badge") { 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 ( return (
<div <div
className="status-badge-container" className={badgeClassName}
style={{ style={badgeStyleVars}
backgroundColor: `${status.color}15`,
border: `1px solid ${status.color}30`,
}}
onClick={handleClick} onClick={handleClick}
data-clickable={!!onClick}
title={getStatusTooltip(status)}
> >
{showDot && (
<span className="status-badge-dot" aria-hidden="true" />
)}
<div className="status-badge-item"> <div className="status-badge-item">
<span className="status-badge-icon"> {showIcon && (
{status.icon ? status.icon : "📝"} <span className="status-badge-icon">
</span> {iconNode}
<span className="status-badge-text"> </span>
{getDisplayName()} )}
</span> {showText && (
<span className="status-badge-text">
{getDisplayName()}
</span>
)}
{isEmptyBadge && (
<span
className="status-badge-empty"
aria-hidden="true"
/>
)}
</div> </div>
</div> </div>
); );
@ -134,17 +180,24 @@ export const StatusDisplay: FC<StatusDisplayProps> = memo(
if (variant === "template") { if (variant === "template") {
return ( return (
<div className="template-status-chip" onClick={handleClick}> <div
className="template-status-chip"
onClick={handleClick}
data-clickable={!!onClick}
>
<span <span
className="template-status-color-dot" className="template-status-color-dot"
style={ style={
{ {
"--dot-color": status.color, "--dot-color": status.color,
} as React.CSSProperties } as CSSProperties
} }
/> />
<span> <span className="template-status-chip__label">
{status.icon ? status.icon : "📝"} {getDisplayName()} <span className="template-status-chip__icon">
{iconNode}
</span>
{getDisplayName()}
</span> </span>
</div> </div>
); );

View file

@ -0,0 +1,52 @@
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>
);
};

View file

@ -0,0 +1,90 @@
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>
);
},
);

View file

@ -2,16 +2,27 @@ import React, { memo, useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { SelectableListItem } from "./SelectableListItem"; import { SelectableListItem } from "./SelectableListItem";
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils"; import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
import { StatusIconPreview } from "./StatusIconPreview";
import { StatusIcon } from "./StatusIcon";
interface StatusOptionProps { interface StatusOptionProps {
status: NoteStatus; status: NoteStatus;
isSelected: boolean; isSelected: boolean;
isFocused: boolean; isFocused: boolean;
onSelect: () => void; onSelect: () => void;
iconFrameMode: "always" | "never";
iconColorMode: "status" | "theme";
} }
export const StatusModalOption: React.FC<StatusOptionProps> = memo( export const StatusModalOption: React.FC<StatusOptionProps> = memo(
({ status, isSelected, isFocused, onSelect }) => { ({
status,
isSelected,
isFocused,
onSelect,
iconFrameMode,
iconColorMode,
}) => {
const displayName = status.templateId const displayName = status.templateId
? `${status.name} (${status.templateId})` ? `${status.name} (${status.templateId})`
: status.name; : status.name;
@ -20,14 +31,40 @@ export const StatusModalOption: React.FC<StatusOptionProps> = memo(
<SelectableListItem <SelectableListItem
selected={isSelected} selected={isSelected}
focused={isFocused} focused={isFocused}
icon={status.icon} 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>
}
onClick={onSelect} onClick={onSelect}
className="note-status-option" className="note-status-option"
title={ title={
status.description ? getStatusTooltip(status) : undefined status.description ? getStatusTooltip(status) : undefined
} }
> >
{displayName} <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>
</SelectableListItem> </SelectableListItem>
); );
}, },
@ -38,6 +75,8 @@ export interface Props {
availableStatuses: NoteStatus[]; availableStatuses: NoteStatus[];
focusedIndex?: number; focusedIndex?: number;
onToggleStatus: (status: NoteStatus, selected: boolean) => void; onToggleStatus: (status: NoteStatus, selected: boolean) => void;
iconFrameMode?: "always" | "never";
iconColorMode?: "status" | "theme";
} }
export const StatusSelector: React.FC<Props> = ({ export const StatusSelector: React.FC<Props> = ({
@ -45,6 +84,8 @@ export const StatusSelector: React.FC<Props> = ({
availableStatuses, availableStatuses,
focusedIndex = -1, focusedIndex = -1,
onToggleStatus, onToggleStatus,
iconFrameMode = "never",
iconColorMode = "status",
}) => { }) => {
const handleSelectStatus = useCallback( const handleSelectStatus = useCallback(
async (status: NoteStatus) => { async (status: NoteStatus) => {
@ -55,23 +96,16 @@ export const StatusSelector: React.FC<Props> = ({
); );
return ( return (
<div <div className="note-status-options" role="listbox">
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) => ( {availableStatuses.map((status, index) => (
<StatusModalOption <StatusModalOption
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`} key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}:${status.lucideIcon ?? ""}`}
status={status} status={status}
isSelected={isStatusSelected(status, currentStatuses)} isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex} isFocused={index === focusedIndex}
onSelect={() => handleSelectStatus(status)} onSelect={() => handleSelectStatus(status)}
iconFrameMode={iconFrameMode}
iconColorMode={iconColorMode}
/> />
))} ))}
</div> </div>

View file

@ -1,11 +1,9 @@
import { PluginSettings } from "types/pluginSettings"; import { PluginSettings } from "types/pluginSettings";
import { import { PREDEFINED_TEMPLATES } from "./predefinedTemplates";
DEFAULT_ENABLED_TEMPLATES,
PREDEFINED_TEMPLATES,
} from "./predefinedTemplates";
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
fileExplorerIconPosition: "absolute-right", fileExplorerIconPosition: "absolute-right",
fileExplorerIconFrame: "never",
fileExplorerIconColorMode: "status",
statusColors: { statusColors: {
active: "var(--text-success)", active: "var(--text-success)",
onHold: "var(--text-warning)", onHold: "var(--text-warning)",
@ -15,26 +13,56 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
}, },
showStatusBar: true, showStatusBar: true,
autoHideStatusBar: false, autoHideStatusBar: false,
templates: [...PREDEFINED_TEMPLATES], enableStatusOverviewPopup: true,
templates: JSON.parse(JSON.stringify(PREDEFINED_TEMPLATES)),
customStatuses: [], customStatuses: [],
showStatusIconsInExplorer: true, showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: true, // Default to hide unknown status hideUnknownStatusInExplorer: true, // Default to hide unknown status
enabledTemplates: DEFAULT_ENABLED_TEMPLATES, fileExplorerColorFileName: false,
fileExplorerColorBlock: false,
fileExplorerLeftBorder: false,
fileExplorerStatusDot: false,
fileExplorerUnderlineFileName: false,
enableExperimentalFeatures: true,
enableStatusDashboard: true,
enableGroupedStatusView: true,
enabledTemplates: PREDEFINED_TEMPLATES.map((t) => t.id),
useCustomStatusesOnly: false, useCustomStatusesOnly: false,
useMultipleStatuses: true, useMultipleStatuses: true,
singleStatusStorageMode: "list",
statusBarShowTemplateName: "auto", // Default to show template names only when needed statusBarShowTemplateName: "auto", // Default to show template names only when needed
tagPrefix: "obsidian-note-status", tagPrefix: "obsidian-note-status",
strictStatuses: false, // Default to show all statuses from frontmatter strictStatuses: false, // Default to show all statuses from frontmatter
quickStatusCommands: ["active", "completed"], // Add default quick commands quickStatusCommands: ["active", "completed"], // Add default quick commands
// Unknown status customization // Unknown status customization
unknownStatusIcon: "❓", unknownStatusIcon: "❓",
unknownStatusLucideIcon: "",
unknownStatusColor: "#8b949e", unknownStatusColor: "#8b949e",
statusBarNoStatusText: "No status", statusBarNoStatusText: "No status",
statusBarShowNoStatusIcon: false, statusBarShowNoStatusIcon: false,
statusBarShowNoStatusText: true, statusBarShowNoStatusText: true,
statusBarBadgeStyle: "accent",
statusBarBadgeContentMode: "icon-text",
vaultSizeLimit: 15000, // Disable dashboard and grouped view for vaults with more notes than this limit vaultSizeLimit: 15000, // Disable dashboard and grouped view for vaults with more notes than this limit
// Editor toolbar button settings // Editor toolbar button settings
showEditorToolbarButton: true, // Default to show the toolbar button showEditorToolbarButton: true, // Default to show the toolbar button
editorToolbarButtonPosition: "right", // Default position on the right editorToolbarButtonPosition: "right", // Default position on the right
editorToolbarButtonDisplay: "all-notes", // Default to show button in all notes 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,
}; };

View file

@ -1,14 +1,72 @@
/**
* 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"; import { StatusTemplate } from "types/pluginSettings";
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [ 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", id: "colorful",
name: "Colorful workflow", name: "Colorful workflow",
description: description:
"A colorful set of workflow statuses with descriptive icons", "A colorful set of workflow statuses with descriptive icons",
language: "English",
type: "General",
authorGithub: "soler1212",
statuses: [ statuses: [
{ {
name: "idea", name: "idea",
@ -65,11 +123,92 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "colorful", 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", id: "minimal",
name: "Minimal workflow", name: "Minimal workflow",
description: "A simplified set of essential workflow statuses", description: "A simplified set of essential workflow statuses",
language: "English",
type: "General",
authorGithub: "soler1212",
statuses: [ statuses: [
{ {
name: "todo", name: "todo",
@ -96,60 +235,15 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "minimal", 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", id: "project",
name: "Project management", name: "Project management",
description: "Status workflow for project management and tracking", description: "Status workflow for project management and tracking",
language: "English",
type: "Project Management",
authorGithub: "soler1212",
statuses: [ statuses: [
{ {
name: "planning", name: "planning",
@ -200,10 +294,51 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
templateId: "project", 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"];

View file

@ -3,6 +3,9 @@ import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
import settingsService from "./settingsService"; import settingsService from "./settingsService";
import eventBus from "./eventBus"; import eventBus from "./eventBus";
import { VIEW_TYPE_EXAMPLE } from "../integrations/views/grouped-status-view"; 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 { export class CommandsService {
private plugin: Plugin; private plugin: Plugin;
@ -70,23 +73,20 @@ export class CommandsService {
// Get the current file data // Get the current file data
const statusService = this.createStatusService(file); const statusService = this.createStatusService(file);
statusService.populateStatuses(); statusService.populateStatuses();
const statuses = const flattened = this.flattenStatuses(
statusService.statuses[ statusService.statuses,
settingsService.settings.tagPrefix );
] || []; const currentStatus = flattened[0];
const currentStatus = statuses.length
? statuses?.[0].name
: undefined;
let nextIndex = 0; let nextIndex = 0;
if (currentStatus) { if (currentStatus) {
const currentIndex = allStatuses.findIndex( const currentIndex = allStatuses.findIndex(
(s) => s.name === currentStatus, (s) =>
s.name === currentStatus.name &&
(s.templateId || null) ===
(currentStatus.templateId || null),
); );
if (currentIndex !== -1) { if (currentIndex !== -1) {
nextIndex = nextIndex = (currentIndex + 1) % allStatuses.length;
currentIndex === -1
? 0
: (currentIndex + 1) % allStatuses.length;
} }
} }
@ -213,10 +213,15 @@ export class CommandsService {
if (!statuses || statuses.length === 0) return false; if (!statuses || statuses.length === 0) return false;
const tagPrefix = settingsService.settings.tagPrefix; const keys = getAllFrontmatterKeys();
const queries = statuses.map( const queries = Array.from(
(status) => `[${tagPrefix}:"${status}"]`, new Set(
statuses.flatMap((status) =>
keys.map((key) => `[${key}:"${status}"]`),
),
),
); );
if (!queries.length) return false;
const query = queries.join(" OR "); const query = queries.join(" OR ");
// @ts-ignore // @ts-ignore
@ -229,15 +234,17 @@ export class CommandsService {
}); });
this.registeredCommands.add("search-by-status"); this.registeredCommands.add("search-by-status");
// Open Status Pane command // Open Status Pane command (experimental)
this.plugin.addCommand({ if (this.shouldEnableGroupedView()) {
id: "open-status-pane", this.plugin.addCommand({
name: "Open status pane", id: "open-status-pane",
callback: async () => { name: "Open status pane",
await this.openStatusPane(); callback: async () => {
}, await this.openStatusPane();
}); },
this.registeredCommands.add("open-status-pane"); });
this.registeredCommands.add("open-status-pane");
}
} }
private async openStatusPane(): Promise<void> { private async openStatusPane(): Promise<void> {
@ -337,12 +344,38 @@ export class CommandsService {
}); });
} }
private flattenStatuses(
groupedStatuses: GroupedStatuses | null | undefined,
): NoteStatus[] {
if (!groupedStatuses) {
return [];
}
const seen = new Set<string>();
const flattened: NoteStatus[] = [];
Object.values(groupedStatuses).forEach((list) => {
list.forEach((status) => {
const identifier = BaseNoteStatusService.formatStatusIdentifier(
{
templateId: status.templateId,
name: status.name,
},
);
if (!seen.has(identifier)) {
seen.add(identifier);
flattened.push(status);
}
});
});
return flattened;
}
private getFileStatuses(file: TFile): string[] { private getFileStatuses(file: TFile): string[] {
const statusService = this.createStatusService(file); const statusService = this.createStatusService(file);
statusService.populateStatuses(); statusService.populateStatuses();
const tagPrefix = settingsService.settings.tagPrefix; const flattened = this.flattenStatuses(statusService.statuses);
const statuses = statusService.statuses[tagPrefix] || []; return flattened.length > 0
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"]; ? flattened.map((s) => s.name)
: ["unknown"];
} }
/** /**
@ -356,6 +389,10 @@ export class CommandsService {
this.registeredCommands.clear(); this.registeredCommands.clear();
} }
private shouldEnableGroupedView(): boolean {
return isExperimentalFeatureEnabled("groupedStatusView");
}
public destroy(): void { public destroy(): void {
// Remove all registered commands properly // Remove all registered commands properly
this.removeRegisteredCommands(); this.removeRegisteredCommands();

View file

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

View file

@ -23,14 +23,16 @@ export class LazyElementObserver {
} }
/** /**
* Sets up observers for a container element * Sets up observers for multiple container elements
*/ */
setupObservers(container: Element): void { setupObservers(containers: Element[]): void {
this.cleanup(); this.cleanup();
this.initializeIntersectionObserver(); this.initializeIntersectionObserver();
this.initializeMutationObserver(container); this.initializeMutationObserver(containers);
this.observeExistingElements(container); containers.forEach((container) =>
this.observeExistingElements(container),
);
} }
/** /**
@ -85,14 +87,16 @@ export class LazyElementObserver {
/** /**
* Initializes mutation observer for DOM changes * Initializes mutation observer for DOM changes
*/ */
private initializeMutationObserver(container: Element): void { private initializeMutationObserver(containers: Element[]): void {
this.mutationObserver = new MutationObserver((mutations) => this.mutationObserver = new MutationObserver((mutations) =>
this.handleMutations(mutations), this.handleMutations(mutations),
); );
this.mutationObserver.observe(container, { containers.forEach((container) => {
childList: true, this.mutationObserver?.observe(container, {
subtree: true, childList: true,
subtree: true,
});
}); });
} }

View file

@ -8,6 +8,10 @@ import {
} from "@/types/noteStatus"; } from "@/types/noteStatus";
import settingsService from "@/core/settingsService"; import settingsService from "@/core/settingsService";
import eventBus from "./eventBus"; 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 { export abstract class BaseNoteStatusService {
static app: App; static app: App;
@ -102,6 +106,44 @@ export abstract class BaseNoteStatusService {
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName); 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[] { protected getStatusMetadataKeys(): string[] {
return [settingsService.settings.tagPrefix]; return [settingsService.settings.tagPrefix];
} }
@ -119,58 +161,208 @@ export abstract class BaseNoteStatusService {
export class NoteStatusService extends BaseNoteStatusService { export class NoteStatusService extends BaseNoteStatusService {
private file: TFile; private file: TFile;
private statusStore: StatusStore;
constructor(file: TFile) { constructor(file: TFile) {
super(); super();
this.file = file; 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 { public populateStatuses(): void {
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys(); const defaultKey = settingsService.settings.tagPrefix;
this.statuses = Object.fromEntries( this.statuses = { [defaultKey]: [] };
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
if (!this.file) { if (!this.file) {
return; return;
} }
const cachedMetadata = const identifiers = this.collectIdentifiersForFile();
BaseNoteStatusService.app.metadataCache.getFileCache(this.file); if (!identifiers.length) {
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) {
return; return;
} }
STATUS_METADATA_KEYS.forEach((key) => { this.statuses[defaultKey] = this.buildStatusesArray(identifiers);
const value = frontmatter[key]; }
if (value) {
let statuses: (NoteStatusType | undefined)[] = [];
if (Array.isArray(value)) {
statuses = value.map((v) =>
this.statusNameToObject(v.toString()),
);
} else {
statuses = [this.statusNameToObject(value.toString())];
}
statuses = statuses.filter((s) => s !== undefined); public getStatusesForKey(frontmatterTagName: string): NoteStatusType[] {
if ( const isValidKey =
statuses.length && !this.isMarkdownFile() &&
!settingsService.settings.useMultipleStatuses frontmatterTagName !== settingsService.settings.tagPrefix
) { ? false
statuses = statuses.slice(0, 1); : true;
}
this.statuses[key] = statuses as NoteStatusType[]; 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;
} }
grouped[key] = statuses;
hasStatuses = true;
}); });
if (!hasStatuses) {
grouped[settingsService.settings.tagPrefix] =
this.statuses[settingsService.settings.tagPrefix] ?? [];
}
return grouped;
} }
async removeStatus( async removeStatus(
frontmatterTagName: string, frontmatterTagName: string,
status: NoteStatus, status: NoteStatus,
): Promise<boolean> { ): Promise<boolean> {
let removed = false; const targetKeys = this.resolveTargetKeys(frontmatterTagName, status, {
includeSourceKey: true,
});
const removed = await this.runAcrossKeys(targetKeys, (key) =>
this.removeStatusFromKey(key, status),
);
if (removed) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return removed;
}
private async removeStatusFromKey(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
const targetIdentifier = status.templateId const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({ ? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId, templateId: status.templateId,
@ -178,88 +370,173 @@ export class NoteStatusService extends BaseNoteStatusService {
}) })
: status.name; : status.name;
await BaseNoteStatusService.app.fileManager.processFrontMatter( return this.statusStore.mutateStatuses(
this.file, this.file,
(frontmatter) => { frontmatterTagName,
const noteStatusFrontmatter = (current) => {
(frontmatter?.[frontmatterTagName] as string[]) || []; if (!current.length) {
if (!noteStatusFrontmatter.length) return; return { hasChanged: false };
if (Array.isArray(noteStatusFrontmatter)) {
// First try to find exact match (scoped or legacy)
let i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === targetIdentifier,
);
// If not found and we're looking for a scoped status, try legacy format
if (i === -1 && status.templateId) {
i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === status.name,
);
}
if (i !== -1) {
noteStatusFrontmatter.splice(i, 1);
removed = true;
}
} }
},
);
if (removed) { let i = current.findIndex(
eventBus.publish("frontmatter-manually-changed", { (statusName: string) => statusName === targetIdentifier,
file: this.file, );
});
} if (i === -1 && status.templateId) {
return removed; i = current.findIndex(
(statusName: string) => statusName === status.name,
);
}
if (i === -1) {
return { hasChanged: false };
}
const nextStatuses = [...current];
nextStatuses.splice(i, 1);
return { hasChanged: true, nextStatuses };
},
this.getStoreOptions(),
);
} }
async clearStatus(frontmatterTagName: string): Promise<boolean> { async clearStatus(frontmatterTagName: string): Promise<boolean> {
await BaseNoteStatusService.app.fileManager.processFrontMatter( const keys = new Set<string>([frontmatterTagName]);
this.file, if (this.isMarkdownFile()) {
(frontmatter) => { getFrontmatterKeysForFile(this.file).forEach((key) =>
if (frontmatterTagName in frontmatter) { keys.add(key),
frontmatter[frontmatterTagName] = []; );
} }
},
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(
this.file,
frontmatterTagName,
(current) => {
if (!current.length) {
return { hasChanged: false };
}
return { hasChanged: true, nextStatuses: [] };
},
this.getStoreOptions(),
); );
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
} }
async overrideStatuses( async overrideStatuses(
frontmatterTagName: string, frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[], 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> { ): Promise<boolean> {
const formattedIdentifiers = statusIdentifiers.map((id) => const formattedIdentifiers = statusIdentifiers.map((id) =>
typeof id === "string" typeof id === "string"
? id ? id
: BaseNoteStatusService.formatStatusIdentifier(id), : BaseNoteStatusService.formatStatusIdentifier(id),
); );
await BaseNoteStatusService.app.fileManager.processFrontMatter(
return this.statusStore.mutateStatuses(
this.file, this.file,
(frontmatter) => { frontmatterTagName,
if ( (current) => {
frontmatterTagName in frontmatter && if (this.statusesAreEqual(current, formattedIdentifiers)) {
Array.isArray(frontmatter[frontmatterTagName]) return { hasChanged: false };
) {
frontmatter[frontmatterTagName].splice(0);
frontmatter[frontmatterTagName].push(
...formattedIdentifiers,
);
} }
frontmatter[frontmatterTagName] = [...formattedIdentifiers]; return {
hasChanged: true,
nextStatuses: formattedIdentifiers,
};
}, },
this.getStoreOptions(),
); );
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
} }
async addStatus( async addStatus(
frontmatterTagName: string, frontmatterTagName: string,
statusIdentifier: StatusIdentifier, statusIdentifier: StatusIdentifier,
): Promise<boolean> { ): Promise<boolean> {
let added = false; const targetKeys = this.resolveTargetKeys(
frontmatterTagName,
statusIdentifier,
);
const added = await this.runAcrossKeys(targetKeys, (key) =>
this.addStatusToKey(key, statusIdentifier),
);
if (added) {
eventBus.publish("status-changed", {
file: this.file,
});
}
return added;
}
private async addStatusToKey(
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
const formattedIdentifier = const formattedIdentifier =
typeof statusIdentifier === "string" typeof statusIdentifier === "string"
? statusIdentifier ? statusIdentifier
@ -267,64 +544,48 @@ export class NoteStatusService extends BaseNoteStatusService {
statusIdentifier, statusIdentifier,
); );
await BaseNoteStatusService.app.fileManager.processFrontMatter( return this.statusStore.mutateStatuses(
this.file, this.file,
(frontmatter) => { frontmatterTagName,
const noteStatusFrontmatter = (current) => {
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!settingsService.settings.useMultipleStatuses) { if (!settingsService.settings.useMultipleStatuses) {
frontmatter[frontmatterTagName] = [formattedIdentifier]; const newValue = [formattedIdentifier];
added = true; if (this.statusesAreEqual(current, newValue)) {
} else { return { hasChanged: false };
// Ensure frontmatter property exists as an array
if (
!frontmatter[frontmatterTagName] ||
!Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName] = [];
}
// 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;
}
} }
return { hasChanged: true, nextStatuses: newValue };
} }
const exactMatch = current.findIndex(
(statusName: string) => statusName === formattedIdentifier,
);
if (exactMatch !== -1) {
return { hasChanged: false };
}
let legacyIndex = -1;
if (
typeof statusIdentifier !== "string" &&
statusIdentifier.templateId
) {
legacyIndex = current.findIndex(
(statusName: string) =>
statusName === statusIdentifier.name,
);
}
const nextStatuses = [...current];
if (legacyIndex !== -1) {
nextStatuses[legacyIndex] = formattedIdentifier;
} else {
nextStatuses.push(formattedIdentifier);
}
return { hasChanged: true, nextStatuses };
}, },
this.getStoreOptions(),
); );
if (added) {
eventBus.publish("frontmatter-manually-changed", {
file: this.file,
});
}
return added;
} }
} }
@ -341,109 +602,60 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
} }
public populateStatuses(): void { public populateStatuses(): void {
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys(); const defaultKey = settingsService.settings.tagPrefix;
this.statuses = { [defaultKey]: [] };
this.statuses = Object.fromEntries( const aggregated = new Set<string>();
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
const allStatuses = new Map<string, Set<string>>();
this.files.forEach((file) => { this.files.forEach((file) => {
const cachedMetadata = let store: StatusStore;
BaseNoteStatusService.app.metadataCache.getFileCache(file); try {
const frontmatter = cachedMetadata?.frontmatter; store = statusStoreManager.getStoreForFile(file);
if (!frontmatter) return; } catch {
return;
}
STATUS_METADATA_KEYS.forEach((key) => { const keys = getFrontmatterKeysForFile(file);
const value = frontmatter[key]; keys.forEach((key) => {
if (value) { const identifiers = store.getStatuses(file, key);
if (!allStatuses.has(key)) { identifiers.forEach((identifier) => {
allStatuses.set(key, new Set()); const normalized = identifier?.toString();
if (normalized && !aggregated.has(normalized)) {
aggregated.add(normalized);
} }
});
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((name) =>
allStatuses.get(key)!.add(name.toString()),
);
}
}); });
}); });
STATUS_METADATA_KEYS.forEach((key) => { if (!aggregated.size) {
const statusNames = allStatuses.get(key); return;
if (statusNames) { }
const statuses = Array.from(statusNames)
.map((name) => this.statusNameToObject(name))
.filter((s) => s !== undefined) as NoteStatusType[];
this.statuses[key] = statuses; const statuses = Array.from(aggregated)
} .map((identifier) => this.statusNameToObject(identifier))
}); .filter((status): status is NoteStatusType => status !== undefined);
this.statuses[defaultKey] = statuses;
} }
async removeStatus( async removeStatus(
frontmatterTagName: string, frontmatterTagName: string,
status: NoteStatus, status: NoteStatus,
): Promise<boolean> { ): Promise<boolean> {
let removedFromAny = false; const removalResults = await Promise.all(
const targetIdentifier = status.templateId this.files.map(async (file) => {
? BaseNoteStatusService.formatStatusIdentifier({ const noteStatusService = new NoteStatusService(file);
templateId: status.templateId, return noteStatusService.removeStatus(
name: status.name, frontmatterTagName,
}) status,
: 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) { if (removedFromAny) {
this.files.forEach((file) => { this.populateStatuses();
eventBus.publish("frontmatter-manually-changed", { file });
});
} }
return removedFromAny; return removedFromAny;
} }
@ -451,55 +663,20 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
frontmatterTagName: string, frontmatterTagName: string,
statusIdentifier: StatusIdentifier, statusIdentifier: StatusIdentifier,
): Promise<boolean> { ): Promise<boolean> {
let addedToAny = false; const results = await Promise.all(
const formattedIdentifier = this.files.map(async (file) => {
typeof statusIdentifier === "string" const noteStatusService = new NoteStatusService(file);
? statusIdentifier return noteStatusService.addStatus(
: BaseNoteStatusService.formatStatusIdentifier( frontmatterTagName,
statusIdentifier, 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) { if (addedToAny) {
this.files.forEach((file) => { this.populateStatuses();
eventBus.publish("frontmatter-manually-changed", { file });
});
} }
return addedToAny; return addedToAny;
} }
@ -515,25 +692,20 @@ export class MultipleNoteStatusService extends BaseNoteStatusService {
: status.name; : status.name;
return this.files.filter((file) => { return this.files.filter((file) => {
const cachedMetadata = let store: StatusStore;
BaseNoteStatusService.app.metadataCache.getFileCache(file); try {
const frontmatter = cachedMetadata?.frontmatter; store = statusStoreManager.getStoreForFile(file);
if (!frontmatter) return false; } catch {
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))
);
} }
// Check for exact match first, then legacy format if scoped
return ( const identifiers = store.getStatuses(file, frontmatterTagName);
value === targetIdentifier || if (!identifiers.length) return false;
(status.templateId && value === status.name)
return identifiers.some(
(identifier) =>
identifier === targetIdentifier ||
(status.templateId && identifier === status.name),
); );
}); });
} }

View file

@ -1,17 +1,78 @@
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings"; import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
import { PluginSettings } from "@/types/pluginSettings"; import { PluginSettings, SyncGroup } from "@/types/pluginSettings";
import { Plugin } from "obsidian"; import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
import { Plugin, TFile, normalizePath } from "obsidian";
import eventBus from "./eventBus"; 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 { class SettingsService {
private plugin: Plugin; private plugin: Plugin;
public settings: PluginSettings; public settings: PluginSettings;
private isWatcherRegistered = false;
constructor() {} constructor() {}
async initialize(plugin: Plugin): Promise<void> { async initialize(plugin: Plugin): Promise<void> {
this.plugin = plugin; this.plugin = plugin;
await this.loadSettings(); const loadedData = await this.loadSettings();
this.migrateLegacySettings(loadedData);
if (this.settings.enableExternalStatusSync) {
await this.loadFromExternalFile();
this.setupExternalFileWatcher();
}
} }
setValue(key: keyof PluginSettings, value: unknown) { setValue(key: keyof PluginSettings, value: unknown) {
@ -21,6 +82,19 @@ class SettingsService {
// const oldValue = this.settings[key]; // TODO: Send the old value // const oldValue = this.settings[key]; // TODO: Send the old value
this.settings[key] = value; this.settings[key] = value;
this.saveSettings().catch(console.error); 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 // INFO: Send the propgation event
eventBus.publish("plugin-settings-changed", { eventBus.publish("plugin-settings-changed", {
key, key,
@ -30,17 +104,215 @@ class SettingsService {
} }
private async loadSettings() { private async loadSettings() {
this.settings = Object.assign( const loadedData = await this.plugin.loadData();
{}, this.settings = this.mergeSettings(DEFAULT_PLUGIN_SETTINGS, loadedData);
DEFAULT_PLUGIN_SETTINGS, this.deduplicateTemplates();
await this.plugin.loadData(), return loadedData;
); }
return this.settings;
/**
* 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;
} }
private async saveSettings() { private async saveSettings() {
await this.plugin.saveData(this.settings); 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(); export default new SettingsService();

31
core/statusKeyHelpers.ts Normal file
View file

@ -0,0 +1,31 @@
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();
};

View file

@ -0,0 +1,45 @@
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();

View file

@ -0,0 +1,80 @@
import { App, TFile } from "obsidian";
import { StatusMutation, StatusStore } from "./types";
export class FrontmatterStatusStore implements StatusStore {
constructor(private readonly app: App) {}
canHandle(file: TFile): boolean {
return file.extension === "md";
}
getStatuses(file: TFile, frontmatterTagName: string): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return [];
return this.normalizeValue(frontmatter[frontmatterTagName]);
}
async mutateStatuses(
file: TFile,
frontmatterTagName: string,
mutator: (current: string[]) => StatusMutation,
options: { storeAsArray: boolean },
): Promise<boolean> {
let changed = false;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const currentStatuses = this.normalizeValue(
frontmatter?.[frontmatterTagName],
);
const mutation = mutator([...currentStatuses]);
if (!mutation.hasChanged) return;
this.writeStatuses(
frontmatter,
frontmatterTagName,
mutation.nextStatuses,
options.storeAsArray,
);
changed = true;
});
return changed;
}
private normalizeValue(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((status) =>
status === undefined || status === null
? undefined
: String(status),
)
.filter((status): status is string => Boolean(status));
}
return [String(value)];
}
private writeStatuses(
frontmatter: Record<string, unknown>,
frontmatterTagName: string,
statuses: string[],
storeAsArray: boolean,
) {
if (!statuses.length) {
if (storeAsArray) {
frontmatter[frontmatterTagName] = [];
} else {
delete frontmatter[frontmatterTagName];
}
return;
}
if (storeAsArray) {
frontmatter[frontmatterTagName] = [...statuses];
} else {
frontmatter[frontmatterTagName] = statuses[0];
}
}
}

View file

@ -0,0 +1,268 @@
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);
}
}

View file

@ -0,0 +1,18 @@
import { TFile } from "obsidian";
export type StatusMutation =
| { hasChanged: false }
| { hasChanged: true; nextStatuses: string[] };
export type StatusMutator = (current: string[]) => StatusMutation;
export interface StatusStore {
canHandle(file: TFile): boolean;
getStatuses(file: TFile, frontmatterTagName: string): string[];
mutateStatuses(
file: TFile,
frontmatterTagName: string,
mutator: StatusMutator,
options: { storeAsArray: boolean },
): Promise<boolean>;
}

78
docs/features.md Normal file
View file

@ -0,0 +1,78 @@
# 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 files statuses—use with caution if you expect ad-hoc values.

126
docs/settings.md Normal file
View file

@ -0,0 +1,126 @@
## 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.

View file

@ -1,15 +1,76 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "process"; import process from "process";
import builtins from "builtin-modules"; import builtins from "builtin-modules";
import fs from "fs";
import path from "path";
const banner = const banner =
`/* `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin if you want to view the source, please visit the github repository of this plugin
*/ */
`; `;
const prod = (process.argv[2] === "production"); /**
* Automatically generates the predefinedTemplates.ts file by scanning the templates directory.
*/
const generateTemplatesIndex = () => {
const templatesDir = "./templates";
const outputFile = "./constants/predefinedTemplates.ts";
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";
// CSS bundling function // CSS bundling function
const buildStyles = async () => { const buildStyles = async () => {

BIN
images/big-picture-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
images/big-picture-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

BIN
images/custom-templates.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

BIN
images/navbar-multiple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
images/status-groups.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -32,7 +32,9 @@ export class CommandsIntegration {
if ( if (
key === "quickStatusCommands" || key === "quickStatusCommands" ||
key === "useMultipleStatuses" || key === "useMultipleStatuses" ||
key === "templates" key === "templates" ||
key === "enableExperimentalFeatures" ||
key === "enableGroupedStatusView"
) { ) {
this.commandsService.destroy(); 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 /// 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

View file

@ -5,12 +5,15 @@ import {
Plugin, Plugin,
TAbstractFile, TAbstractFile,
TFile, TFile,
TFolder,
WorkspaceLeaf, WorkspaceLeaf,
} from "obsidian"; } from "obsidian";
import { import {
BaseNoteStatusService,
MultipleNoteStatusService, MultipleNoteStatusService,
NoteStatusService, NoteStatusService,
} from "@/core/noteStatusService"; } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
export class ContextMenuIntegration { export class ContextMenuIntegration {
private static instance: ContextMenuIntegration | null = null; private static instance: ContextMenuIntegration | null = null;
@ -46,33 +49,32 @@ export class ContextMenuIntegration {
const tFiles = files.filter( const tFiles = files.filter(
(f): f is TFile => f instanceof TFile, (f): f is TFile => f instanceof TFile,
); );
if (tFiles.length) { if (!tFiles.length) {
this.openMultipleFilesStatusesModal(tFiles);
} else {
new Notice( new Notice(
"The selected files are not valid to add status, just .md files can have status in this plugin version", "Select at least one file to change its status.",
); );
return;
} }
this.openMultipleFilesStatusesModal(tFiles);
}); });
}); });
}, },
), ),
); );
this.plugin.registerEvent( this.plugin.registerEvent(
this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => { this.plugin.app.workspace.on("file-menu", (menu, file) => {
menu.addItem((item) => { if (file instanceof TFile) {
item.setTitle("Change note state") menu.addItem((item) => {
.setIcon("rotate-ccw") // Lucide icon item.setTitle("Change note state")
.onClick(async () => { .setIcon("rotate-ccw")
if (file instanceof TFile) { .onClick(async () => {
this.openSingleFileStatusesModal(file); this.openSingleFileStatusesModal(file);
} else { });
new Notice( });
"The selected file is not valid to add status, just .md files can have status in this plugin version", } else if (file instanceof TFolder) {
); this.addFolderStatusActions(menu, file);
} }
});
});
}), }),
); );
@ -84,17 +86,17 @@ export class ContextMenuIntegration {
item.setTitle("Change note status") item.setTitle("Change note status")
.setIcon("rotate-ccw") // Lucide icon .setIcon("rotate-ccw") // Lucide icon
.onClick(async () => { .onClick(async () => {
if (view.file) { if (
if (view.file instanceof TFile) { !view.file ||
this.openSingleFileStatusesModal( !(view.file instanceof TFile)
view.file, ) {
); new Notice(
} else { "The selected item is not a valid file.",
new Notice( );
"The selected file is not valid to add status, just .md files can have status in this plugin version", return;
);
}
} }
this.openSingleFileStatusesModal(view.file);
}); });
}); });
}, },
@ -117,6 +119,92 @@ 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() { destroy() {
this.currentLeaf = null; this.currentLeaf = null;
this.noteStatusService = null; this.noteStatusService = null;

View file

@ -7,6 +7,11 @@ import {
import { NoteStatusService } from "@/core/noteStatusService"; import { NoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService"; import settingsService from "@/core/settingsService";
import { GroupedStatuses } from "@/types/noteStatus"; import { GroupedStatuses } from "@/types/noteStatus";
import {
getPrimaryStatus,
getUnknownStatusColor,
resolveStatusColor,
} from "@/utils/statusColor";
import { Plugin, TFile, View } from "obsidian"; import { Plugin, TFile, View } from "obsidian";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration"; import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
@ -19,7 +24,17 @@ export class FileExplorerIntegration implements IElementProcessor {
private readonly EVENT_SUBSCRIPTION_ID = private readonly EVENT_SUBSCRIPTION_ID =
"file-explorer-integration-subscription1"; "file-explorer-integration-subscription1";
private readonly FILE_EXPLORER_TYPE = "file-explorer"; private readonly FILE_EXPLORER_TYPE = "file-explorer";
private readonly CONTAINER_SELECTOR = ".nav-files-container"; 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";
constructor(plugin: Plugin) { constructor(plugin: Plugin) {
this.plugin = plugin; this.plugin = plugin;
@ -33,7 +48,7 @@ export class FileExplorerIntegration implements IElementProcessor {
this.setupWorkspaceIntegration(); this.setupWorkspaceIntegration();
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
({ file }) => { ({ file }) => {
const element = this.findFileElement(file.path); const element = this.findFileElement(file.path);
if (!element) return; if (!element) return;
@ -54,9 +69,18 @@ export class FileExplorerIntegration implements IElementProcessor {
key === "customStatuses" || key === "customStatuses" ||
key === "useMultipleStatuses" || key === "useMultipleStatuses" ||
key === "tagPrefix" || key === "tagPrefix" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" || key === "strictStatuses" ||
key === "fileExplorerIconPosition" || key === "fileExplorerIconPosition" ||
key === "fileExplorerIconFrame" ||
key === "fileExplorerIconColorMode" ||
key === "fileExplorerColorFileName" ||
key === "fileExplorerColorBlock" ||
key === "fileExplorerLeftBorder" ||
key === "fileExplorerStatusDot" ||
key === "fileExplorerUnderlineFileName" ||
key === "unknownStatusIcon" || key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" || key === "unknownStatusColor" ||
key === "templates" key === "templates"
) { ) {
@ -71,18 +95,13 @@ export class FileExplorerIntegration implements IElementProcessor {
private getFileNoteStatusService( private getFileNoteStatusService(
dataPath: string, dataPath: string,
): NoteStatusService | null { ): NoteStatusService | null {
if (!dataPath.endsWith(".md")) { const abstractFile =
this.plugin.app.vault.getAbstractFileByPath(dataPath);
if (!(abstractFile instanceof TFile)) {
return null; return null;
} }
const file = this.plugin.app.vault.getAbstractFileByPath( const noteStatusService = new NoteStatusService(abstractFile);
dataPath,
) as TFile;
if (!file) {
return null;
}
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses(); noteStatusService.populateStatuses();
return noteStatusService; return noteStatusService;
} }
@ -96,34 +115,62 @@ export class FileExplorerIntegration implements IElementProcessor {
} }
if (dataPath) { if (dataPath) {
const textEl = element.querySelector( const textEl = element.querySelector(
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content", ".nav-file-title-content, .tree-item-inner, .nav-folder-title-content, .nn-file-name",
); );
if (!textEl) { if (!textEl) {
return; return;
} }
const noteStatusService = this.getFileNoteStatusService(dataPath); 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) { if (noteStatusService) {
this.render(textEl, noteStatusService.statuses); this.render(
textEl,
statuses,
settingsService.settings.tagPrefix,
);
} }
} }
} }
private getUnknownStatusConfig() { private getUnknownStatusConfig() {
const settings = settingsService.settings;
return { return {
icon: settings.unknownStatusIcon || "❓", icon: settingsService.settings.unknownStatusIcon || "❓",
color: settings.unknownStatusColor || "#8b949e", lucideIcon: settingsService.settings.unknownStatusLucideIcon || "",
color: getUnknownStatusColor(),
}; };
} }
render(element: Element, statuses: GroupedStatuses): void { render(
// Remove existing icon element: Element,
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`); statuses: GroupedStatuses,
if (existingIcon) { defaultTagName: string,
existingIcon.remove(); ): 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();
} }
if (!settingsService.settings.showStatusIconsInExplorer) { if (!settingsService.settings.showStatusIconsInExplorer) {
@ -138,16 +185,26 @@ export class FileExplorerIntegration implements IElementProcessor {
positionClassName = "custom-icon__absolute-right"; positionClassName = "custom-icon__absolute-right";
} }
const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] }); const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] });
icon.setAttribute("contenteditable", "false");
const root = createRoot(icon); const root = createRoot(icon);
root.render( root.render(
<FileExplorerIcon <FileExplorerIcon
statuses={statuses} statuses={statuses}
onMouseEnter={(s) => this.openModalInfo(s)} defaultTagName={defaultTagName}
onMouseEnter={this.openModalInfo}
onMouseLeave={this.closeModalInfo} onMouseLeave={this.closeModalInfo}
hideUnknownStatus={ hideUnknownStatus={
settingsService.settings.hideUnknownStatusInExplorer settingsService.settings.hideUnknownStatusInExplorer
} }
unknownStatusConfig={this.getUnknownStatusConfig()} unknownStatusConfig={this.getUnknownStatusConfig()}
iconFrameMode={
settingsService.settings.fileExplorerIconFrame || "never"
}
iconColorMode={
settingsService.settings.fileExplorerIconColorMode ||
"status"
}
/>, />,
); );
@ -155,33 +212,36 @@ export class FileExplorerIntegration implements IElementProcessor {
settingsService.settings.fileExplorerIconPosition === settingsService.settings.fileExplorerIconPosition ===
"file-name-right" "file-name-right"
) { ) {
element.append(icon); element.insertAdjacentElement("afterend", icon);
} else { } else {
element.prepend(icon); element.insertAdjacentElement("beforebegin", icon);
} }
} }
private openModalInfo(statuses: GroupedStatuses) { private openModalInfo = ({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) => {
if (!this.plugin) { if (!this.plugin) {
return; return;
} }
StatusesInfoPopup.open(statuses); StatusesInfoPopup.open({ statuses, defaultTagName });
} };
private closeModalInfo() { private closeModalInfo = () => {
StatusesInfoPopup.close(); StatusesInfoPopup.close();
} };
/** /**
* Cleanup integration and unsubscribe from events * Cleanup integration and unsubscribe from events
*/ */
destroy(): void { destroy(): void {
this.observerService.cleanup(); this.observerService.cleanup();
eventBus.unsubscribe("status-changed", this.EVENT_SUBSCRIPTION_ID);
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
this.EVENT_SUBSCRIPTION_ID,
);
eventBus.unsubscribe(
"frontmatter-manually-changed",
"fileExplorerIntegrationSubscription2", "fileExplorerIntegrationSubscription2",
); );
} }
@ -208,15 +268,20 @@ export class FileExplorerIntegration implements IElementProcessor {
} }
/** /**
* Finds file element in the file explorer * Finds file element in the file explorer views
*/ */
private findFileElement(filePath: string): HTMLElement | null { private findFileElement(filePath: string): HTMLElement | null {
const fileExplorerView = this.getFileExplorerView(); const views = this.getFileExplorerViews();
if (!fileExplorerView) return null;
return fileExplorerView.containerEl.querySelector( for (const view of views) {
`[data-path="${filePath}"]`, const element = view.containerEl.querySelector(
) as HTMLElement; `[data-path="${filePath}"]`,
) as HTMLElement | null;
if (element) return element;
}
return null;
} }
/** /**
@ -237,34 +302,219 @@ export class FileExplorerIntegration implements IElementProcessor {
} }
/** /**
* Patches the file explorer with observers * Patches the file explorers with observers
*/ */
private patchFileExplorer(): void { private patchFileExplorer(): void {
const container = this.getFileExplorerContainer(); const containers = this.getFileExplorerContainers();
if (!container) return; if (containers.length === 0) return;
this.observerService.setupObservers(container); this.observerService.setupObservers(containers);
} }
/** /**
* Gets the file explorer view * Gets the file explorer views
*/ */
private getFileExplorerView(): View | null { private getFileExplorerViews(): View[] {
const leaves = this.plugin.app.workspace.getLeavesOfType( const leaves = [
this.FILE_EXPLORER_TYPE, ...this.plugin.app.workspace.getLeavesOfType(
); this.FILE_EXPLORER_TYPE,
return leaves[0]?.view || null; ),
...this.plugin.app.workspace.getLeavesOfType(
this.NOTEBOOK_NAVIGATOR_TYPE,
),
];
return leaves.map((leaf) => leaf.view);
} }
/** /**
* Gets the file explorer container element * Gets the file explorer container elements
*/ */
private getFileExplorerContainer(): Element | null { private getFileExplorerContainers(): Element[] {
const fileExplorerView = this.getFileExplorerView(); const views = this.getFileExplorerViews();
if (!fileExplorerView) return null;
return fileExplorerView.containerEl.querySelector( return views
this.CONTAINER_SELECTOR, .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);
} }
} }

View file

@ -11,11 +11,14 @@ import {
BaseNoteStatusService, BaseNoteStatusService,
} from "@/core/noteStatusService"; } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus"; import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
export class StatusModalIntegration extends Modal { export class StatusModalIntegration extends Modal {
private root: Root | null = null; private root: Root | null = null;
private static instance: StatusModalIntegration | null = null; private static instance: StatusModalIntegration | null = null;
private selectorService: SelectorService; private selectorService: SelectorService;
private activeStatus?: NoteStatus;
constructor(app: App) { constructor(app: App) {
super(app); super(app);
@ -24,6 +27,7 @@ export class StatusModalIntegration extends Modal {
static open( static open(
app: App, app: App,
noteStatusService: NoteStatusService | MultipleNoteStatusService, noteStatusService: NoteStatusService | MultipleNoteStatusService,
activeStatus?: NoteStatus,
) { ) {
if (StatusModalIntegration.instance) { if (StatusModalIntegration.instance) {
throw new Error("Status Modal is already open"); throw new Error("Status Modal is already open");
@ -34,7 +38,7 @@ export class StatusModalIntegration extends Modal {
); );
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
() => { () => {
// TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations // TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations
// FIXME: Line 27 // FIXME: Line 27
@ -44,6 +48,7 @@ export class StatusModalIntegration extends Modal {
"statusModalIntegrationSubscription1", "statusModalIntegrationSubscription1",
); );
StatusModalIntegration.instance.activeStatus = activeStatus;
StatusModalIntegration.instance.open(); StatusModalIntegration.instance.open();
} }
@ -116,6 +121,15 @@ export class StatusModalIntegration extends Modal {
this.selectorService.noteStatusService.statuses ?? {} this.selectorService.noteStatusService.statuses ?? {}
} }
filesQuantity={filesQuantity} filesQuantity={filesQuantity}
templates={settingsService.settings.templates}
iconFrameMode={
settingsService.settings.fileExplorerIconFrame || "never"
}
iconColorMode={
settingsService.settings.fileExplorerIconColorMode ||
"status"
}
activeStatus={this.activeStatus}
onRemoveStatus={this.onRemoveStatus} onRemoveStatus={this.onRemoveStatus}
onSelectStatus={this.onSelectStatus} onSelectStatus={this.onSelectStatus}
/>, />,
@ -132,7 +146,7 @@ export class StatusModalIntegration extends Modal {
StatusModalIntegration.instance = null; StatusModalIntegration.instance = null;
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
"statusModalIntegrationSubscription1", "statusModalIntegrationSubscription1",
); );
} }

View file

@ -1,20 +1,35 @@
import { createRoot, Root } from "react-dom/client"; import { createRoot, Root } from "react-dom/client";
import { GroupedStatuses } from "@/types/noteStatus"; import { GroupedStatuses } from "@/types/noteStatus";
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup"; import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
import settingsService from "@/core/settingsService";
export class StatusesInfoPopup { export class StatusesInfoPopup {
private root: Root | null = null; private root: Root | null = null;
private static instance: StatusesInfoPopup | null = null; private static instance: StatusesInfoPopup | null = null;
private element: HTMLElement | null = null; private element: HTMLElement | null = null;
private statuses: GroupedStatuses; private statuses: GroupedStatuses;
private defaultTagName: string;
private constructor() {} private constructor() {}
static open(statuses: GroupedStatuses) { static open({
statuses,
defaultTagName,
}: {
statuses: GroupedStatuses;
defaultTagName: string;
}) {
// Always ensure previous instance is cleaned up before showing or when disabled
StatusesInfoPopup.close(); StatusesInfoPopup.close();
const isEnabled =
settingsService.settings?.enableStatusOverviewPopup ?? true;
if (!isEnabled) {
return;
}
StatusesInfoPopup.instance = new StatusesInfoPopup(); StatusesInfoPopup.instance = new StatusesInfoPopup();
StatusesInfoPopup.instance.statuses = statuses; StatusesInfoPopup.instance.statuses = statuses;
StatusesInfoPopup.instance.defaultTagName = defaultTagName;
StatusesInfoPopup.instance.show(); StatusesInfoPopup.instance.show();
} }
@ -31,7 +46,12 @@ export class StatusesInfoPopup {
document.body.appendChild(this.element); document.body.appendChild(this.element);
this.root = createRoot(this.element); this.root = createRoot(this.element);
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />); this.root.render(
<StatusFileInfoPopup
statuses={this.statuses}
defaultTagName={this.defaultTagName}
/>,
);
} }
private destroy() { private destroy() {

View file

@ -1,18 +1,17 @@
import { createRoot, Root } from "react-dom/client"; import { createRoot, Root } from "react-dom/client";
import eventBus from "core/eventBus"; import eventBus from "core/eventBus";
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian"; import { Plugin } from "obsidian";
import settingsService from "@/core/settingsService"; import settingsService from "@/core/settingsService";
import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton";
import { StatusBar } from "@/components/StatusBar/StatusBar"; import { StatusBar } from "@/components/StatusBar/StatusBar";
import { NoteStatusService } from "@/core/noteStatusService"; import { NoteStatusService } from "@/core/noteStatusService";
import { NoteStatus } from "@/types/noteStatus";
export class StatusBarIntegration { export class StatusBarIntegration {
private static instance: StatusBarIntegration | null = null; private static instance: StatusBarIntegration | null = null;
private root: Root | null = null; private root: Root | null = null;
private plugin: Plugin; private plugin: Plugin;
private statusBarContainer: HTMLElement; private statusBarContainer: HTMLElement | null = null;
private noteStatusService: NoteStatusService | null = null; private noteStatusService: NoteStatusService | null = null;
private currentLeaf: WorkspaceLeaf | null = null;
constructor(plugin: Plugin) { constructor(plugin: Plugin) {
if (StatusBarIntegration.instance) { if (StatusBarIntegration.instance) {
@ -23,16 +22,10 @@ export class StatusBarIntegration {
} }
async integrate() { async integrate() {
this.statusBarContainer = this.plugin.addStatusBarItem();
this.statusBarContainer.classList.add("mod-clickable");
eventBus.subscribe( eventBus.subscribe(
"active-file-change", "active-file-change",
({ leaf }) => { () => {
if (this.isValidMarkdownLeaf(leaf)) { this.handleActiveFileChange();
this.currentLeaf = leaf;
this.handleActiveFileChange().catch(console.error);
}
}, },
"statusBarIntegrationSubscription1", "statusBarIntegrationSubscription1",
); );
@ -46,6 +39,12 @@ export class StatusBarIntegration {
if (key === "statusBarShowTemplateName") { if (key === "statusBarShowTemplateName") {
this.render(); // INFO: Force a render to set disabled or enabled this.render(); // INFO: Force a render to set disabled or enabled
} }
if (
key === "statusBarBadgeStyle" ||
key === "statusBarBadgeContentMode"
) {
this.render();
}
if (key === "enabledTemplates" || key === "templates") { if (key === "enabledTemplates" || key === "templates") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
} }
@ -58,6 +57,9 @@ export class StatusBarIntegration {
if (key === "tagPrefix") { if (key === "tagPrefix") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render 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") { if (key === "useCustomStatusesOnly") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
} }
@ -66,6 +68,7 @@ export class StatusBarIntegration {
} }
if ( if (
key === "unknownStatusIcon" || key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" || key === "unknownStatusColor" ||
key === "statusBarNoStatusText" || key === "statusBarNoStatusText" ||
key === "statusBarShowNoStatusIcon" || key === "statusBarShowNoStatusIcon" ||
@ -78,7 +81,7 @@ export class StatusBarIntegration {
); );
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
() => { () => {
this.handleActiveFileChange().catch(console.error); this.handleActiveFileChange().catch(console.error);
}, },
@ -86,30 +89,20 @@ export class StatusBarIntegration {
); );
} }
private async handleActiveFileChange() { private async handleActiveFileChange(): Promise<void> {
this.extractStatusesFromLeaf(this.currentLeaf); const file = this.plugin.app.workspace.getActiveFile();
if (!file) {
this.noteStatusService = null;
this.render();
return;
}
this.noteStatusService = new NoteStatusService(file);
this.noteStatusService.populateStatuses();
this.render(); this.render();
} }
private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) { private openStatusModal(activeStatus?: NoteStatus) {
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) { if (!this.noteStatusService) {
throw new Error( throw new Error(
"open status modal failed bcse there is no noteStatusService available", "open status modal failed bcse there is no noteStatusService available",
@ -117,6 +110,7 @@ export class StatusBarIntegration {
} }
eventBus.publish("triggered-open-modal", { eventBus.publish("triggered-open-modal", {
statusService: this.noteStatusService, statusService: this.noteStatusService,
activeStatus,
}); });
} }
@ -127,38 +121,55 @@ export class StatusBarIntegration {
showIcon: settings.statusBarShowNoStatusIcon || false, showIcon: settings.statusBarShowNoStatusIcon || false,
showText: settings.statusBarShowNoStatusText ?? true, showText: settings.statusBarShowNoStatusText ?? true,
icon: settings.unknownStatusIcon || "❓", icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e", 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() { private render() {
if (!this.root) {
this.root = createRoot(this.statusBarContainer);
this.statusBarContainer.style.padding = "unset";
}
if (!settingsService.settings.showStatusBar) { if (!settingsService.settings.showStatusBar) {
this.root.render( this.destroyStatusBarItem();
<StatusBarEnableButton return;
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() { destroy() {
@ -171,15 +182,11 @@ export class StatusBarIntegration {
"statusBarIntegrationSubscription2", "statusBarIntegrationSubscription2",
); );
eventBus.unsubscribe( eventBus.unsubscribe(
"plugin-settings-changed", "status-changed",
"statusBarIntegrationSubscription3", "statusBarIntegrationSubscription3",
); );
if (this.root) { this.destroyStatusBarItem();
this.root.unmount();
this.root = null;
}
this.currentLeaf = null;
this.noteStatusService = null; this.noteStatusService = null;
StatusBarIntegration.instance = null; StatusBarIntegration.instance = null;
} }

View file

@ -1,6 +1,6 @@
import { createRoot, Root } from "react-dom/client"; import { createRoot, Root } from "react-dom/client";
import eventBus from "core/eventBus"; import eventBus from "core/eventBus";
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian"; import { MarkdownView, Plugin, WorkspaceLeaf, TFile } from "obsidian";
import settingsService from "@/core/settingsService"; import settingsService from "@/core/settingsService";
import { EditorToolbarButton } from "@/components/Toolbar/EditorToolbarButton"; import { EditorToolbarButton } from "@/components/Toolbar/EditorToolbarButton";
import { NoteStatusService } from "@/core/noteStatusService"; import { NoteStatusService } from "@/core/noteStatusService";
@ -43,6 +43,11 @@ export class EditorToolbarIntegration {
this.handleLayoutChange(); this.handleLayoutChange();
}), }),
); );
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-open", (file) => {
this.handleFileOpen(file);
}),
);
eventBus.subscribe( eventBus.subscribe(
"active-file-change", "active-file-change",
@ -66,6 +71,9 @@ export class EditorToolbarIntegration {
if (key === "tagPrefix") { if (key === "tagPrefix") {
this.refreshAllButtons(); this.refreshAllButtons();
} }
if (key === "statusFrontmatterMappings") {
this.refreshAllButtons();
}
if (key === "useCustomStatusesOnly") { if (key === "useCustomStatusesOnly") {
this.refreshAllButtons(); this.refreshAllButtons();
} }
@ -74,6 +82,7 @@ export class EditorToolbarIntegration {
} }
if ( if (
key === "unknownStatusIcon" || key === "unknownStatusIcon" ||
key === "unknownStatusLucideIcon" ||
key === "unknownStatusColor" key === "unknownStatusColor"
) { ) {
this.renderAllButtons(); this.renderAllButtons();
@ -92,7 +101,7 @@ export class EditorToolbarIntegration {
); );
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
({ file }) => { ({ file }) => {
// Find the button for the specific file and refresh only that one // Find the button for the specific file and refresh only that one
for (const [leaf, leafButton] of this.leafButtons.entries()) { for (const [leaf, leafButton] of this.leafButtons.entries()) {
@ -120,6 +129,7 @@ export class EditorToolbarIntegration {
private handleActiveLeafChange() { private handleActiveLeafChange() {
this.syncButtons(); this.syncButtons();
this.refreshAllButtons();
} }
private handleDisplayModeChange() { private handleDisplayModeChange() {
@ -130,7 +140,7 @@ export class EditorToolbarIntegration {
const targetLeaves = new Set(this.getTargetLeaves()); const targetLeaves = new Set(this.getTargetLeaves());
// Remove buttons that shouldn't exist // Remove buttons that shouldn't exist
for (const leaf of this.leafButtons.keys()) { for (const leaf of Array.from(this.leafButtons.keys())) {
if (!targetLeaves.has(leaf)) { if (!targetLeaves.has(leaf)) {
this.removeButtonForLeaf(leaf); this.removeButtonForLeaf(leaf);
} }
@ -148,6 +158,36 @@ export class EditorToolbarIntegration {
this.syncButtons(); 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) { private createButtonForLeaf(leaf: WorkspaceLeaf) {
if (!this.isValidMarkdownLeaf(leaf)) { if (!this.isValidMarkdownLeaf(leaf)) {
return; return;
@ -163,15 +203,11 @@ export class EditorToolbarIntegration {
return; return;
} }
const markdownView = leaf.view as MarkdownView; const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
if (!markdownView.file) { if (!noteStatusService) {
return; return;
} }
// Create note status service for this leaf
const noteStatusService = new NoteStatusService(markdownView.file);
noteStatusService.populateStatuses();
// Create button element // Create button element
const buttonElement = this.createButtonElement(leaf); const buttonElement = this.createButtonElement(leaf);
if (!buttonElement) { if (!buttonElement) {
@ -214,6 +250,10 @@ export class EditorToolbarIntegration {
} }
private getTargetLeaves(): WorkspaceLeaf[] { private getTargetLeaves(): WorkspaceLeaf[] {
if (!settingsService.settings.showEditorToolbarButton) {
return [];
}
const leaves: WorkspaceLeaf[] = []; const leaves: WorkspaceLeaf[] = [];
if ( if (
@ -298,12 +338,13 @@ export class EditorToolbarIntegration {
} }
private renderButtonForLeaf(leafButton: LeafButton): void { private renderButtonForLeaf(leafButton: LeafButton): void {
const statuses =
leafButton.noteStatusService?.getStatusesByAllKeys() || {};
leafButton.root.render( leafButton.root.render(
<EditorToolbarButton <EditorToolbarButton
statuses={leafButton.noteStatusService?.statuses || {}} statuses={statuses}
onClick={() => defaultTagName={settingsService.settings.tagPrefix}
this.openStatusModal(leafButton.noteStatusService) onClick={() => this.openStatusModal(leafButton.leaf)}
}
unknownStatusConfig={this.getUnknownStatusConfig()} unknownStatusConfig={this.getUnknownStatusConfig()}
/>, />,
); );
@ -311,14 +352,12 @@ export class EditorToolbarIntegration {
private refreshAllButtons(): void { private refreshAllButtons(): void {
for (const [leaf, leafButton] of this.leafButtons.entries()) { for (const [leaf, leafButton] of this.leafButtons.entries()) {
const markdownView = leaf.view as MarkdownView; const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
if (markdownView.file) { if (!noteStatusService) {
leafButton.noteStatusService = new NoteStatusService( continue;
markdownView.file,
);
leafButton.noteStatusService.populateStatuses();
this.renderButtonForLeaf(leafButton);
} }
leafButton.noteStatusService = noteStatusService;
this.renderButtonForLeaf(leafButton);
} }
} }
@ -340,21 +379,37 @@ export class EditorToolbarIntegration {
this.syncButtons(); this.syncButtons();
} }
private openStatusModal(noteStatusService: NoteStatusService) { private openStatusModal(leaf: WorkspaceLeaf) {
const noteStatusService = this.buildNoteStatusServiceForLeaf(leaf);
if (!noteStatusService) { if (!noteStatusService) {
throw new Error( console.error(
"open status modal failed because there is no noteStatusService available", "open status modal failed because there is no markdown file in the provided leaf",
); );
return;
} }
eventBus.publish("triggered-open-modal", { eventBus.publish("triggered-open-modal", {
statusService: noteStatusService, 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() { private getUnknownStatusConfig() {
const settings = settingsService.settings; const settings = settingsService.settings;
return { return {
icon: settings.unknownStatusIcon || "❓", icon: settings.unknownStatusIcon || "❓",
lucideIcon: settings.unknownStatusLucideIcon || "",
color: settings.unknownStatusColor || "#8b949e", color: settings.unknownStatusColor || "#8b949e",
}; };
} }
@ -369,7 +424,7 @@ export class EditorToolbarIntegration {
"editorToolbarIntegrationSubscription2", "editorToolbarIntegrationSubscription2",
); );
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
"editorToolbarIntegrationSubscription3", "editorToolbarIntegrationSubscription3",
); );

View file

@ -1,10 +1,13 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian"; import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client"; import { Root, createRoot } from "react-dom/client";
import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView"; import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView";
import { BaseNoteStatusService } from "@/core/noteStatusService"; import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import eventBus from "@/core/eventBus"; import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view"; export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-dashboard-view";
@ -18,6 +21,7 @@ interface StatusItem {
name: string; name: string;
color: string; color: string;
icon?: string; icon?: string;
lucideIcon?: string;
} }
interface FilesByStatus { interface FilesByStatus {
@ -57,28 +61,19 @@ export class GroupedDashboardView extends ItemView {
name: status.name, name: status.name,
color: status.color || "", color: status.color || "",
icon: status.icon, icon: status.icon,
lucideIcon: status.lucideIcon,
}); });
private getAllFiles = (): FileItem[] => { private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); const tFiles = BaseNoteStatusService.app.vault.getFiles();
return tFiles.map(this.convertTFileToFileItem); return tFiles.map(this.convertTFileToFileItem);
}; };
private processFiles = (files: FileItem[]): GroupedByStatus => { private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {}; const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix]; const statusMetadataKeys = getAllFrontmatterKeys();
const availableStatuses = const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses(); 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) => { statusMetadataKeys.forEach((key) => {
result[key] = {}; result[key] = {};
@ -91,41 +86,27 @@ export class GroupedDashboardView extends ItemView {
}); });
files.forEach((file) => { files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path, file.path,
) as TFile; ) as TFile;
if (!tFile) return; if (!tFile) return;
const cachedMetadata = const noteStatusService = new NoteStatusService(tFile);
BaseNoteStatusService.app.metadataCache.getFileCache(tFile); noteStatusService.populateStatuses();
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => { statusMetadataKeys.forEach((key) => {
const value = frontmatter[key]; const statusesForKey = noteStatusService.getStatusesForKey(key);
if (value) { if (!statusesForKey.length) return;
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);
}
if (resolvedStatus) { statusesForKey.forEach((status) => {
const statusKey = resolvedStatus.templateId const statusKey = status.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}` ? `${status.templateId}:${status.name}`
: resolvedStatus.name; : status.name;
if (!result[key][statusKey]) { if (!result[key][statusKey]) {
result[key][statusKey] = []; result[key][statusKey] = [];
} }
result[key][statusKey].push(file); result[key][statusKey].push(file);
} });
});
}
}); });
}); });
@ -153,7 +134,7 @@ export class GroupedDashboardView extends ItemView {
private subscribeToEvents = (onDataChange: () => void) => { private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
onDataChange, onDataChange,
"grouped-dashboard-view-subscription", "grouped-dashboard-view-subscription",
); );
@ -166,6 +147,7 @@ export class GroupedDashboardView extends ItemView {
key === "useCustomStatusesOnly" || key === "useCustomStatusesOnly" ||
key === "customStatuses" || key === "customStatuses" ||
key === "useMultipleStatuses" || key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" key === "strictStatuses"
) { ) {
onDataChange(); onDataChange();
@ -180,7 +162,7 @@ export class GroupedDashboardView extends ItemView {
return () => { return () => {
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
"grouped-dashboard-view-subscription", "grouped-dashboard-view-subscription",
); );
eventBus.unsubscribe( eventBus.unsubscribe(

View file

@ -6,10 +6,14 @@ import {
GroupedStatusView as GroupedStatusViewComponent, GroupedStatusView as GroupedStatusViewComponent,
StatusItem, StatusItem,
} from "@/components/GroupedStatusView/GroupedStatusView"; } from "@/components/GroupedStatusView/GroupedStatusView";
import { BaseNoteStatusService } from "@/core/noteStatusService"; import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import eventBus from "@/core/eventBus"; import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService"; import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
export const VIEW_TYPE_EXAMPLE = "grouped-status-view"; export const VIEW_TYPE_EXAMPLE = "grouped-status-view";
@ -42,28 +46,19 @@ export class GroupedStatusView extends ItemView {
name: status.name, name: status.name,
color: status.color || "white", color: status.color || "white",
icon: status.icon, icon: status.icon,
lucideIcon: status.lucideIcon,
}); });
private getAllFiles = (): FileItem[] => { private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles(); const tFiles = BaseNoteStatusService.app.vault.getFiles();
return tFiles.map(this.convertTFileToFileItem); return tFiles.map(this.convertTFileToFileItem);
}; };
private processFiles = (files: FileItem[]): GroupedByStatus => { private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {}; const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix]; const statusMetadataKeys = getAllFrontmatterKeys();
const availableStatuses = const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses(); 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) => { statusMetadataKeys.forEach((key) => {
result[key] = {}; result[key] = {};
@ -76,41 +71,27 @@ export class GroupedStatusView extends ItemView {
}); });
files.forEach((file) => { files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath( const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path, file.path,
) as TFile; ) as TFile;
if (!tFile) return; if (!tFile) return;
const cachedMetadata = const noteStatusService = new NoteStatusService(tFile);
BaseNoteStatusService.app.metadataCache.getFileCache(tFile); noteStatusService.populateStatuses();
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => { statusMetadataKeys.forEach((key) => {
const value = frontmatter[key]; const statusesForKey = noteStatusService.getStatusesForKey(key);
if (value) { if (!statusesForKey.length) return;
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);
}
if (resolvedStatus) { statusesForKey.forEach((status) => {
const statusKey = resolvedStatus.templateId const statusKey = status.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}` ? `${status.templateId}:${status.name}`
: resolvedStatus.name; : status.name;
if (!result[key][statusKey]) { if (!result[key][statusKey]) {
result[key][statusKey] = []; result[key][statusKey] = [];
} }
result[key][statusKey].push(file); result[key][statusKey].push(file);
} });
});
}
}); });
}); });
@ -138,7 +119,7 @@ export class GroupedStatusView extends ItemView {
private subscribeToEvents = (onDataChange: () => void) => { private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
onDataChange, onDataChange,
"grouped-status-view-subscription", "grouped-status-view-subscription",
); );
@ -151,6 +132,7 @@ export class GroupedStatusView extends ItemView {
key === "useCustomStatusesOnly" || key === "useCustomStatusesOnly" ||
key === "customStatuses" || key === "customStatuses" ||
key === "useMultipleStatuses" || key === "useMultipleStatuses" ||
key === "statusFrontmatterMappings" ||
key === "strictStatuses" || key === "strictStatuses" ||
key === "vaultSizeLimit" key === "vaultSizeLimit"
) { ) {
@ -166,7 +148,7 @@ export class GroupedStatusView extends ItemView {
return () => { return () => {
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
"grouped-status-view-subscription", "grouped-status-view-subscription",
); );
eventBus.unsubscribe( eventBus.unsubscribe(
@ -183,14 +165,14 @@ export class GroupedStatusView extends ItemView {
container.addClass("grouped-status-view-container"); container.addClass("grouped-status-view-container");
// Check if vault exceeds the size limit // Check if vault exceeds the size limit
const files = BaseNoteStatusService.app.vault.getMarkdownFiles(); const files = BaseNoteStatusService.app.vault.getFiles();
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000; const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) { if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
// Show disabled message // Show disabled message
container.createEl("div", { container.createEl("div", {
cls: "grouped-status-view-disabled", cls: "grouped-status-view-disabled",
text: `Grouped Status View disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, text: `Grouped Status View disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
}); });
return; return;
} }

View file

@ -10,6 +10,7 @@ import settingsService from "@/core/settingsService";
import eventBus from "@/core/eventBus"; import eventBus from "@/core/eventBus";
import { VaultStats } from "@/components/StatusDashboard/useVaultStats"; import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
import { NoteStatus } from "@/types/noteStatus"; import { NoteStatus } from "@/types/noteStatus";
import { getAllFrontmatterKeys } from "@/core/statusKeyHelpers";
interface AppWithCommands extends App { interface AppWithCommands extends App {
commands: { commands: {
@ -58,10 +59,10 @@ export class StatusDashboardView extends ItemView {
} }
private calculateVaultStats = (): VaultStats => { private calculateVaultStats = (): VaultStats => {
const files = this.app.vault.getMarkdownFiles(); const files = this.app.vault.getFiles();
const availableStatuses = const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses(); BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix]; const statusMetadataKeys = getAllFrontmatterKeys();
let notesWithStatus = 0; let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {}; const statusDistribution: Record<string, number> = {};
@ -80,49 +81,26 @@ export class StatusDashboardView extends ItemView {
}); });
files.forEach((file) => { files.forEach((file) => {
const cachedMetadata = this.app.metadataCache.getFileCache(file); const noteStatusService = new NoteStatusService(file);
const frontmatter = cachedMetadata?.frontmatter; noteStatusService.populateStatuses();
if (!frontmatter) return;
let hasAnyStatus = false; let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => { statusMetadataKeys.forEach((key) => {
const value = frontmatter[key]; const statuses = noteStatusService.getStatusesForKey(key);
if (value) { if (!statuses.length) return;
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value]; hasAnyStatus = true;
statusNames.forEach((statusName) => { tagDistribution[key]++;
const statusStr = statusName.toString();
// Determine the scoped identifier to use statuses.forEach((status) => {
let scopedIdentifier: string; const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
if (statusStr.includes(":")) { : status.name;
// Already scoped if (!statusDistribution.hasOwnProperty(scopedIdentifier)) {
scopedIdentifier = statusStr; statusDistribution[scopedIdentifier] = 0;
} else { }
// Legacy status - find first template that has this status statusDistribution[scopedIdentifier]++;
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) { if (hasAnyStatus) {
@ -150,10 +128,19 @@ export class StatusDashboardView extends ItemView {
const noteStatusService = new NoteStatusService(activeFile); const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses(); 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 = { this.currentNote = {
file: activeFile, file: activeFile,
statuses: noteStatusService.statuses, statuses: statusesByKey,
lastModified: activeFile.stat.mtime, lastModified: activeFile.stat.mtime,
}; };
this.renderDashboard(); this.renderDashboard();
@ -247,12 +234,19 @@ export class StatusDashboardView extends ItemView {
private findUnassignedNotes() { private findUnassignedNotes() {
const files = this.app.vault.getMarkdownFiles(); const files = this.app.vault.getMarkdownFiles();
const statusKeys = getAllFrontmatterKeys();
const filesWithoutStatus = files.filter((file) => { const filesWithoutStatus = files.filter((file) => {
const cachedMetadata = this.app.metadataCache.getFileCache(file); const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter; const frontmatter = cachedMetadata?.frontmatter;
return ( if (!frontmatter) return true;
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
); return !statusKeys.some((key) => {
const value = frontmatter[key];
if (Array.isArray(value)) {
return value.length > 0;
}
return Boolean(value);
});
}); });
if (filesWithoutStatus.length === 0) { if (filesWithoutStatus.length === 0) {
@ -265,8 +259,7 @@ export class StatusDashboardView extends ItemView {
); );
// Create a search query to find files without the status tag // Create a search query to find files without the status tag
const tagPrefix = settingsService.settings.tagPrefix; const query = statusKeys.map((key) => `-[${key}:]`).join(" ");
const query = `-[${tagPrefix}:]`;
// @ts-ignore // @ts-ignore
this.app.internalPlugins.plugins[ this.app.internalPlugins.plugins[
@ -275,8 +268,10 @@ export class StatusDashboardView extends ItemView {
} }
private searchBySpecificStatus(statusName: string) { private searchBySpecificStatus(statusName: string) {
const tagPrefix = settingsService.settings.tagPrefix; const keys = getAllFrontmatterKeys();
const query = `[${tagPrefix}:"${statusName}"]`; const query = keys
.map((key) => `[${key}:"${statusName}"]`)
.join(" OR ");
// @ts-ignore // @ts-ignore
this.app.internalPlugins.plugins[ this.app.internalPlugins.plugins[
@ -305,14 +300,14 @@ export class StatusDashboardView extends ItemView {
container.addClass("status-dashboard-view-container"); container.addClass("status-dashboard-view-container");
// Check if vault exceeds the size limit // Check if vault exceeds the size limit
const files = this.app.vault.getMarkdownFiles(); const files = this.app.vault.getFiles();
const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000; const vaultSizeLimit = settingsService.settings.vaultSizeLimit || 15000;
if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) { if (vaultSizeLimit > 0 && files.length > vaultSizeLimit) {
// Show disabled message // Show disabled message
container.createEl("div", { container.createEl("div", {
cls: "status-dashboard-disabled", cls: "status-dashboard-disabled",
text: `Dashboard disabled: Vault has ${files.length} notes (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`, text: `Dashboard disabled: Vault has ${files.length} files (limit: ${vaultSizeLimit}). You can adjust this limit in plugin settings.`,
}); });
return; return;
} }
@ -329,7 +324,7 @@ export class StatusDashboardView extends ItemView {
}; };
eventBus.subscribe( eventBus.subscribe(
"frontmatter-manually-changed", "status-changed",
handleVaultChange, handleVaultChange,
"status-dashboard-vault-subscription", "status-dashboard-vault-subscription",
); );
@ -348,6 +343,7 @@ export class StatusDashboardView extends ItemView {
key === "customStatuses" || key === "customStatuses" ||
key === "useMultipleStatuses" || key === "useMultipleStatuses" ||
key === "strictStatuses" || key === "strictStatuses" ||
key === "statusFrontmatterMappings" ||
key === "vaultSizeLimit" key === "vaultSizeLimit"
) { ) {
handleVaultChange(); handleVaultChange();
@ -366,7 +362,7 @@ export class StatusDashboardView extends ItemView {
async onClose() { async onClose() {
// Clean up event listeners // Clean up event listeners
eventBus.unsubscribe( eventBus.unsubscribe(
"frontmatter-manually-changed", "status-changed",
"status-dashboard-vault-subscription", "status-dashboard-vault-subscription",
); );
eventBus.unsubscribe( eventBus.unsubscribe(

157
main.tsx
View file

@ -1,9 +1,12 @@
import { Plugin, WorkspaceLeaf } from "obsidian"; import { Plugin, WorkspaceLeaf, TFile } from "obsidian";
import StatusBarIntegration from "integrations/status-bar/status-bar"; import StatusBarIntegration from "integrations/status-bar/status-bar";
import eventBus from "core/eventBus"; import eventBus from "core/eventBus";
import { PluginSettingIntegration } from "./integrations/settings/pluginSettings"; import { PluginSettingIntegration } from "./integrations/settings/pluginSettings";
import settingsService from "./core/settingsService"; import settingsService from "./core/settingsService";
import { BaseNoteStatusService } from "./core/noteStatusService"; import {
BaseNoteStatusService,
NoteStatusService,
} from "./core/noteStatusService";
import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration"; import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration";
import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration"; import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration";
import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration"; import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration";
@ -17,6 +20,9 @@ import {
StatusDashboardView, StatusDashboardView,
VIEW_TYPE_STATUS_DASHBOARD, VIEW_TYPE_STATUS_DASHBOARD,
} from "./integrations/views/status-dashboard-view"; } 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 { export default class NoteStatusPlugin extends Plugin {
private statusBarIntegration: StatusBarIntegration; private statusBarIntegration: StatusBarIntegration;
@ -25,10 +31,12 @@ export default class NoteStatusPlugin extends Plugin {
private fileExplorerIntegration: FileExplorerIntegration; private fileExplorerIntegration: FileExplorerIntegration;
private commandsIntegration: CommandsIntegration; private commandsIntegration: CommandsIntegration;
private editorToolbarIntegration: EditorToolbarIntegration; private editorToolbarIntegration: EditorToolbarIntegration;
private experimentalRibbonShortcuts: Map<string, HTMLElement> = new Map();
async onload() { async onload() {
BaseNoteStatusService.initialize(this.app); BaseNoteStatusService.initialize(this.app);
await this.loadPluginSettings(); await this.loadPluginSettings();
await statusStoreManager.initialize(this);
// INFO: initialize all integrations // INFO: initialize all integrations
Promise.all([ Promise.all([
@ -50,13 +58,7 @@ export default class NoteStatusPlugin extends Plugin {
(leaf) => new StatusDashboardView(leaf), (leaf) => new StatusDashboardView(leaf),
); );
this.addRibbonIcon("list-tree", "Open grouped status view", () => { this.syncExperimentalFeatureShortcuts();
this.activateView();
});
this.addRibbonIcon("activity", "Open status dashboard", () => {
this.activateDashboard();
});
} }
async onunload() { async onunload() {
@ -67,12 +69,18 @@ export default class NoteStatusPlugin extends Plugin {
this.commandsIntegration?.destroy(); this.commandsIntegration?.destroy();
this.editorToolbarIntegration?.destroy(); this.editorToolbarIntegration?.destroy();
this.pluginSettingsIntegration?.destroy(); this.pluginSettingsIntegration?.destroy();
this.experimentalRibbonShortcuts.forEach((el) => el.remove());
this.experimentalRibbonShortcuts.clear();
// Clean up event subscriptions // Clean up event subscriptions
eventBus.unsubscribe( eventBus.unsubscribe(
"triggered-open-modal", "triggered-open-modal",
"main-triggered-open-modal-subscriptor", "main-triggered-open-modal-subscriptor",
); );
eventBus.unsubscribe(
"plugin-settings-changed",
"main-status-popup-setting-subscriptor",
);
} }
async activateView() { async activateView() {
@ -141,6 +149,58 @@ 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() { private async loadEventBus() {
// Propagate to custom event bus the new active file // Propagate to custom event bus the new active file
this.app.workspace.on( this.app.workspace.on(
@ -153,18 +213,91 @@ export default class NoteStatusPlugin extends Plugin {
// Propagate to custom event bus the manually frontmatter data // Propagate to custom event bus the manually frontmatter data
this.registerEvent( this.registerEvent(
this.app.metadataCache.on("changed", (file) => { this.app.metadataCache.on("changed", (file) => {
eventBus.publish("frontmatter-manually-changed", { file }); eventBus.publish("status-changed", { file });
}), }),
); );
// Register listeners // Register listeners
eventBus.subscribe( eventBus.subscribe(
"triggered-open-modal", "triggered-open-modal",
({ statusService }) => { ({ statusService, activeStatus }) => {
StatusModalIntegration.open(this.app, statusService); StatusModalIntegration.open(
this.app,
statusService,
activeStatus,
);
}, },
"main-triggered-open-modal-subscriptor", "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() { async loadPluginSettings() {

View file

@ -1,19 +1,19 @@
{ {
"id": "note-status", "id": "note-status",
"name": "Note Status", "name": "Note Status",
"version": "2.2.0", "version": "3.1.2",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"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.", "description": "Track and change note statuses across your vault with templates, bulk folder actions, dashboards, and non-Markdown support.",
"author": "Aleix Soler", "author": "Aleix Soler",
"authorUrl": "https://aleixsoler.com", "authorUrl": "https://aleixsoler.com",
"fundingUrl": { "fundingUrl": {
"PayPal": "https://paypal.me/aleixsoler" "PayPal": "https://paypal.me/aleixsoler",
"Buy Me a Coffee": "https://buymeacoffee.com/aleixsoler"
}, },
"isDesktopOnly": false, "isDesktopOnly": false,
"license": "MIT", "license": "MIT",
"icon": "tag", "icon": "tag",
"repository": "https://github.com/devonthesofa/obsidian-note-status", "repository": "https://github.com/devonthesofa/obsidian-note-status",
"issues": "https://github.com/devonthesofa/obsidian-note-status/issues", "issues": "https://github.com/devonthesofa/obsidian-note-status/issues",
"releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.",
"css": "styles.css" "css": "styles.css"
} }

3205
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

1420
styles.css

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,62 @@
/* ==========================================================================
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;
}

View file

@ -11,6 +11,62 @@
right: 0; 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 { .status-wrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
@ -34,6 +90,17 @@
transform: scale(1.05); 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 */ /* Use data attribute for dynamic colors */
.status-minimal[data-status-color] .status-minimal__count { .status-minimal[data-status-color] .status-minimal__count {
background: var(--interactive-accent); background: var(--interactive-accent);
@ -42,6 +109,9 @@
.status-minimal__icon { .status-minimal__icon {
font-size: 11px; font-size: 11px;
line-height: 1; line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
filter: grayscale(0.2); filter: grayscale(0.2);
transition: filter var(--anim-duration-fast) ease; transition: filter var(--anim-duration-fast) ease;
} }
@ -102,6 +172,54 @@
color: var(--text-muted); color: var(--text-muted);
font-style: italic; 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 Groups in Popup */
.status-group { .status-group {

View file

@ -0,0 +1,47 @@
/* ==========================================================================
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;
}

View file

@ -0,0 +1,84 @@
.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);
}

View file

@ -78,25 +78,18 @@
/* Status Group */ /* Status Group */
.grouped-status-group { .grouped-status-group {
margin-bottom: var(--size-4-2); margin-bottom: 2px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m); border-radius: var(--radius-m);
overflow: hidden; 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 { .grouped-status-group__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--size-4-2) var(--size-4-3); padding: 4px 8px;
cursor: pointer; cursor: pointer;
border-radius: var(--radius-s);
transition: background var(--anim-duration-fast) ease; transition: background var(--anim-duration-fast) ease;
position: relative; position: relative;
} }
@ -105,8 +98,8 @@
background: var(--background-modifier-hover); background: var(--background-modifier-hover);
} }
.grouped-status-group__header:focus { .grouped-status-group__header:focus-visible {
outline: 2px solid var(--color-accent); outline: 2px solid var(--interactive-accent);
outline-offset: -2px; outline-offset: -2px;
} }
@ -138,40 +131,46 @@
/* Files List */ /* Files List */
.grouped-status-files { .grouped-status-files {
background: var(--background-secondary); margin-bottom: var(--size-2-2);
border-top: 1px solid var(--background-modifier-border);
} }
.grouped-status-files-list { .grouped-status-files-list {
max-height: 300px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
.grouped-status-file-item { .grouped-status-file-item {
padding: var(--size-2-3) var(--size-4-3);
cursor: pointer; cursor: pointer;
transition: background var(--anim-duration-fast) ease; transition: background var(--anim-duration-fast) ease;
border-bottom: 1px solid var(--background-modifier-border-hover); border-radius: var(--radius-s);
margin-top: 1px;
} }
.grouped-status-file-item:hover { .grouped-status-file-item:hover .tree-item-self {
background: var(--background-modifier-hover); background: var(--background-modifier-hover);
} }
.grouped-status-file-item:last-child { .grouped-status-file-content {
border-bottom: none; display: flex;
flex-direction: column;
width: 100%;
} }
.grouped-status-file-name { .grouped-status-file-name {
font-weight: var(--font-medium); font-weight: var(--font-medium);
color: var(--text-normal); color: var(--text-normal);
margin-bottom: 2px; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.grouped-status-file-path { .grouped-status-file-path {
font-size: var(--font-ui-small); font-size: var(--font-ui-smaller);
color: var(--text-muted); color: var(--text-muted);
opacity: 0.8; opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
/* Load More */ /* Load More */

Some files were not shown because too many files have changed in this diff Show more