From 2fe31700f9c8463ab534b78fe402cf84df4fe000 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 11:32:38 +0100 Subject: [PATCH 1/5] 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 --- .../SettingsUI/SynchronizationSettings.tsx | 73 ++++++++++ components/SettingsUI/index.tsx | 14 ++ constants/defaultSettings.ts | 2 + core/settingsService.ts | 130 +++++++++++++++++- types/pluginSettings.ts | 2 + 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 components/SettingsUI/SynchronizationSettings.tsx diff --git a/components/SettingsUI/SynchronizationSettings.tsx b/components/SettingsUI/SynchronizationSettings.tsx new file mode 100644 index 0000000..90b3d55 --- /dev/null +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -0,0 +1,73 @@ +import { PluginSettings } from "@/types/pluginSettings"; +import React from "react"; +import { SettingItem } from "./SettingItem"; +import settingsService from "@/core/settingsService"; + +export type Props = { + settings: PluginSettings; + onChange: (key: keyof PluginSettings, value: unknown) => void; +}; + +export const SynchronizationSettings: React.FC = ({ + settings, + onChange, +}) => { + const handleExport = async () => { + await settingsService.syncToExternalFile(); + alert("Plugin settings exported successfully."); + }; + + const handleImport = async () => { + await settingsService.loadFromExternalFile(); + alert("Plugin settings imported successfully."); + }; + + return ( +
+

Multi-device Synchronization

+

+ Keep all your plugin settings (statuses, templates, UI + preferences, etc.) in sync across devices using a file in your + vault. +

+ + + + onChange("enableExternalStatusSync", e.target.checked) + } + /> + + + + + onChange("externalStatusSyncPath", e.target.value) + } + placeholder="_note-status-sync.json" + /> + + +
+ + +
+
+ ); +}; diff --git a/components/SettingsUI/index.tsx b/components/SettingsUI/index.tsx index 894b5aa..aa3ab03 100644 --- a/components/SettingsUI/index.tsx +++ b/components/SettingsUI/index.tsx @@ -4,6 +4,7 @@ import { TemplateSettings } from "./TemplateSettings"; import { BehaviourSettings } from "./BehaviourSettings"; import { CustomStatusSettings } from "./CustomStatusSettings"; import { QuickCommandsSettings } from "./QuickCommandsSettings"; +import { SynchronizationSettings } from "./SynchronizationSettings"; import { EditorToolbarSettings, ExperimentalSettings, @@ -25,6 +26,7 @@ type SectionId = | "file-explorer" | "unknown-appearance" | "behavior-storage" + | "synchronization" | "experimental-features"; type SectionDefinition = { @@ -137,6 +139,18 @@ const SettingsUI: React.FC = ({ settings, onChange }) => { /> ), }, + { + id: "synchronization", + label: "Synchronization", + description: + "Keep custom statuses and templates in sync across multiple devices.", + content: ( + + ), + }, { id: "experimental-features", label: "Experimental Features", diff --git a/constants/defaultSettings.ts b/constants/defaultSettings.ts index 5a1a271..773759a 100644 --- a/constants/defaultSettings.ts +++ b/constants/defaultSettings.ts @@ -55,4 +55,6 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { applyStatusRecursivelyToSubfolders: false, statusFrontmatterMappings: [], writeMappedTagsToDefault: false, + enableExternalStatusSync: false, + externalStatusSyncPath: "_note-status-sync.json", }; diff --git a/core/settingsService.ts b/core/settingsService.ts index 068ef4a..7dd564e 100644 --- a/core/settingsService.ts +++ b/core/settingsService.ts @@ -1,17 +1,23 @@ import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings"; import { PluginSettings } from "@/types/pluginSettings"; -import { Plugin } from "obsidian"; +import { Plugin, TFile, normalizePath } from "obsidian"; import eventBus from "./eventBus"; class SettingsService { private plugin: Plugin; public settings: PluginSettings; + private isWatcherRegistered = false; constructor() {} async initialize(plugin: Plugin): Promise { this.plugin = plugin; await this.loadSettings(); + + if (this.settings.enableExternalStatusSync) { + await this.loadFromExternalFile(); + this.setupExternalFileWatcher(); + } } setValue(key: keyof PluginSettings, value: unknown) { @@ -21,6 +27,19 @@ class SettingsService { // const oldValue = this.settings[key]; // TODO: Send the old value this.settings[key] = value; this.saveSettings().catch(console.error); + + // Handle external sync + if (this.settings.enableExternalStatusSync) { + this.syncToExternalFile().catch(console.error); + } + + if (key === "enableExternalStatusSync") { + if (value) { + this.syncToExternalFile().catch(console.error); + this.setupExternalFileWatcher(); + } + } + // INFO: Send the propgation event eventBus.publish("plugin-settings-changed", { key, @@ -30,17 +49,116 @@ class SettingsService { } private async loadSettings() { - this.settings = Object.assign( - {}, - DEFAULT_PLUGIN_SETTINGS, - await this.plugin.loadData(), - ); + const loadedData = await this.plugin.loadData(); + this.settings = this.mergeSettings(DEFAULT_PLUGIN_SETTINGS, loadedData); return this.settings; } + /** + * Deep merges loaded settings with defaults to prevent data loss for nested objects like statusColors. + */ + private mergeSettings( + defaults: PluginSettings, + loaded: Partial | 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() { await this.plugin.saveData(this.settings); } + + async syncToExternalFile() { + if (!this.settings.enableExternalStatusSync) return; + + const path = normalizePath(this.settings.externalStatusSyncPath); + const data = { + ...this.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() { + if (!this.settings.enableExternalStatusSync) 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); + + let hasChanged = false; + const keys = Object.keys(data).filter( + (k) => k !== "updatedAt", + ) as Array; + + for (const key of keys) { + if (!(key in this.settings)) 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)[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(); diff --git a/types/pluginSettings.ts b/types/pluginSettings.ts index 8af2769..843979a 100644 --- a/types/pluginSettings.ts +++ b/types/pluginSettings.ts @@ -70,5 +70,7 @@ export type PluginSettings = { applyStatusRecursivelyToSubfolders: boolean; // Whether to show recursive folder context option statusFrontmatterMappings: StatusFrontmatterMapping[]; // Custom mappings between templates/statuses and frontmatter keys writeMappedTagsToDefault: boolean; // Whether mapped tags should also write to the default tag + enableExternalStatusSync: boolean; // Whether to sync statuses to an external file + externalStatusSyncPath: string; // Path to the external sync file [key: string]: unknown; }; From 403c4113e49e227d5bde771b7f2c7825f3dd62f9 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 11:39:40 +0100 Subject: [PATCH 2/5] fix: allow manual settings sync when automatic sync is disabled --- components/SettingsUI/SynchronizationSettings.tsx | 4 ++-- core/settingsService.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/SettingsUI/SynchronizationSettings.tsx b/components/SettingsUI/SynchronizationSettings.tsx index 90b3d55..15a1f8c 100644 --- a/components/SettingsUI/SynchronizationSettings.tsx +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -13,12 +13,12 @@ export const SynchronizationSettings: React.FC = ({ onChange, }) => { const handleExport = async () => { - await settingsService.syncToExternalFile(); + await settingsService.syncToExternalFile(true); alert("Plugin settings exported successfully."); }; const handleImport = async () => { - await settingsService.loadFromExternalFile(); + await settingsService.loadFromExternalFile(true); alert("Plugin settings imported successfully."); }; diff --git a/core/settingsService.ts b/core/settingsService.ts index 7dd564e..9c859e5 100644 --- a/core/settingsService.ts +++ b/core/settingsService.ts @@ -80,8 +80,8 @@ class SettingsService { await this.plugin.saveData(this.settings); } - async syncToExternalFile() { - if (!this.settings.enableExternalStatusSync) return; + async syncToExternalFile(force = false) { + if (!this.settings.enableExternalStatusSync && !force) return; const path = normalizePath(this.settings.externalStatusSyncPath); const data = { @@ -99,8 +99,8 @@ class SettingsService { } } - async loadFromExternalFile() { - if (!this.settings.enableExternalStatusSync) return; + 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; From e5c456b0aaf642d1cd7c46037f43b893e84ca3c3 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 11:45:54 +0100 Subject: [PATCH 3/5] feat: implement selective settings synchronization --- .../SettingsUI/SynchronizationSettings.tsx | 105 ++++++++++++++++-- constants/defaultSettings.ts | 1 + core/settingsService.ts | 81 +++++++++++++- types/pluginSettings.ts | 3 + 4 files changed, 179 insertions(+), 11 deletions(-) diff --git a/components/SettingsUI/SynchronizationSettings.tsx b/components/SettingsUI/SynchronizationSettings.tsx index 15a1f8c..fb300b3 100644 --- a/components/SettingsUI/SynchronizationSettings.tsx +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -1,4 +1,4 @@ -import { PluginSettings } from "@/types/pluginSettings"; +import { PluginSettings, SyncGroup } from "@/types/pluginSettings"; import React from "react"; import { SettingItem } from "./SettingItem"; import settingsService from "@/core/settingsService"; @@ -22,18 +22,52 @@ export const SynchronizationSettings: React.FC = ({ alert("Plugin settings imported successfully."); }; + 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: "statuses", + label: "Statuses & Templates", + description: "Custom statuses, templates, and workflow rules.", + }, + { + id: "appearance", + label: "Appearance", + description: "Colors, icons, and UI element styles.", + }, + { + id: "behavior", + label: "Behavior & Storage", + description: "Frontmatter keys, mappings, and general logic.", + }, + { + id: "features", + label: "Commands & Features", + description: "Quick commands and experimental feature toggles.", + }, + ]; + return (

Multi-device Synchronization

- Keep all your plugin settings (statuses, templates, UI - preferences, etc.) in sync across devices using a file in your - vault. + Keep your plugin settings in sync across devices using a file in + your vault.

= ({ /> +
+

Selective Synchronization

+

+ Choose which groups of settings should be included in the + synchronization. +

+
+ {syncGroups.map((group) => ( +
toggleGroup(group.id)} + > +
+ {}} // Handled by parent div onClick + style={{ marginRight: "8px" }} + /> + {group.label} +
+
+ {group.description} +
+
+ ))} +
+
+
- - + +
); diff --git a/constants/defaultSettings.ts b/constants/defaultSettings.ts index 773759a..d70f5f0 100644 --- a/constants/defaultSettings.ts +++ b/constants/defaultSettings.ts @@ -57,4 +57,5 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { writeMappedTagsToDefault: false, enableExternalStatusSync: false, externalStatusSyncPath: "_note-status-sync.json", + syncGroups: ["statuses", "appearance", "behavior", "features"], }; diff --git a/core/settingsService.ts b/core/settingsService.ts index 9c859e5..09e418f 100644 --- a/core/settingsService.ts +++ b/core/settingsService.ts @@ -1,8 +1,61 @@ import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings"; -import { PluginSettings } from "@/types/pluginSettings"; +import { PluginSettings, SyncGroup } from "@/types/pluginSettings"; import { Plugin, TFile, normalizePath } from "obsidian"; import eventBus from "./eventBus"; +export const SETTINGS_GROUPS: Record = { + statuses: [ + "templates", + "customStatuses", + "enabledTemplates", + "useCustomStatusesOnly", + "useMultipleStatuses", + "singleStatusStorageMode", + "strictStatuses", + ], + appearance: [ + "fileExplorerIconPosition", + "fileExplorerIconFrame", + "fileExplorerIconColorMode", + "statusColors", + "showStatusBar", + "autoHideStatusBar", + "statusBarShowTemplateName", + "statusBarBadgeStyle", + "statusBarBadgeContentMode", + "showStatusIconsInExplorer", + "hideUnknownStatusInExplorer", + "fileExplorerColorFileName", + "fileExplorerColorBlock", + "fileExplorerLeftBorder", + "fileExplorerStatusDot", + "fileExplorerUnderlineFileName", + "unknownStatusIcon", + "unknownStatusLucideIcon", + "unknownStatusColor", + "statusBarNoStatusText", + "statusBarShowNoStatusIcon", + "statusBarShowNoStatusText", + "showEditorToolbarButton", + "editorToolbarButtonPosition", + "editorToolbarButtonDisplay", + ], + behavior: [ + "tagPrefix", + "statusFrontmatterMappings", + "writeMappedTagsToDefault", + "applyStatusRecursivelyToSubfolders", + "vaultSizeLimit", + "enableStatusOverviewPopup", + ], + features: [ + "quickStatusCommands", + "enableExperimentalFeatures", + "enableStatusDashboard", + "enableGroupedStatusView", + ], +}; + class SettingsService { private plugin: Plugin; public settings: PluginSettings; @@ -84,8 +137,22 @@ class SettingsService { if (!this.settings.enableExternalStatusSync && !force) return; const path = normalizePath(this.settings.externalStatusSyncPath); + + // Filter settings based on selected groups + const keysToSync = new Set(); + this.settings.syncGroups.forEach((group) => { + SETTINGS_GROUPS[group].forEach((key) => keysToSync.add(key)); + }); + + const filteredSettings: Partial = {}; + keysToSync.forEach((key) => { + (filteredSettings as Record)[key] = + this.settings[key]; + }); + const data = { - ...this.settings, + ...filteredSettings, + syncGroups: this.settings.syncGroups, // Always include meta-settings updatedAt: new Date().toISOString(), }; @@ -108,13 +175,19 @@ class SettingsService { 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(); + this.settings.syncGroups.forEach((group) => { + SETTINGS_GROUPS[group].forEach((key) => allowedKeys.add(key)); + }); + let hasChanged = false; const keys = Object.keys(data).filter( - (k) => k !== "updatedAt", + (k) => k !== "updatedAt" && k !== "syncGroups", ) as Array; for (const key of keys) { - if (!(key in this.settings)) continue; + if (!(key in this.settings) || !allowedKeys.has(key)) continue; const newValue = data[key]; const currentValue = this.settings[key]; diff --git a/types/pluginSettings.ts b/types/pluginSettings.ts index 843979a..fc25208 100644 --- a/types/pluginSettings.ts +++ b/types/pluginSettings.ts @@ -22,6 +22,8 @@ export interface StatusTemplate { statuses: NoteStatus[]; } +export type SyncGroup = "statuses" | "appearance" | "behavior" | "features"; + export type PluginSettings = { fileExplorerIconPosition: | "absolute-right" @@ -72,5 +74,6 @@ export type PluginSettings = { writeMappedTagsToDefault: boolean; // Whether mapped tags should also write to the default tag enableExternalStatusSync: boolean; // Whether to sync statuses to an external file externalStatusSyncPath: string; // Path to the external sync file + syncGroups: SyncGroup[]; // Selected groups of settings to synchronize [key: string]: unknown; }; From f97bca5fa917d8c0b095e3b995853154b45e0773 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 12:35:49 +0100 Subject: [PATCH 4/5] 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 --- .../SettingsUI/CustomStatusSettings.tsx | 19 +- .../SettingsUI/SynchronizationSettings.tsx | 272 +++++++++++------- components/SettingsUI/TemplateSettings.tsx | 10 +- constants/defaultSettings.ts | 12 +- core/settingsService.ts | 41 +-- core/statusStores/nonMarkdownStatusStore.ts | 112 +++++++- main.tsx | 2 +- types/pluginSettings.ts | 11 +- 8 files changed, 331 insertions(+), 148 deletions(-) diff --git a/components/SettingsUI/CustomStatusSettings.tsx b/components/SettingsUI/CustomStatusSettings.tsx index 3c38696..1ca8a1c 100644 --- a/components/SettingsUI/CustomStatusSettings.tsx +++ b/components/SettingsUI/CustomStatusSettings.tsx @@ -16,7 +16,7 @@ export const CustomStatusSettings: React.FC = ({ onChange, }) => { const addNewCustomStatus = () => { - const currentStatuses = [...settings.customStatuses]; + const currentStatuses = [...(settings.customStatuses || [])]; currentStatuses.push({ name: "", icon: "", @@ -27,7 +27,7 @@ export const CustomStatusSettings: React.FC = ({ const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = ( index, ) => { - const currentStatuses = [...settings.customStatuses]; + const currentStatuses = [...(settings.customStatuses || [])]; currentStatuses.splice(index, 1); onChange("customStatuses", currentStatuses); }; @@ -37,7 +37,7 @@ export const CustomStatusSettings: React.FC = ({ column, value, ) => { - const currentStatuses = [...settings.customStatuses]; + const currentStatuses = [...(settings.customStatuses || [])]; const target = currentStatuses[index]; if (target) { @@ -48,7 +48,7 @@ export const CustomStatusSettings: React.FC = ({ const moveCustomStatusUp = (index: number) => { if (index <= 0) return; - const currentStatuses = [...settings.customStatuses]; + const currentStatuses = [...(settings.customStatuses || [])]; [currentStatuses[index - 1], currentStatuses[index]] = [ currentStatuses[index], currentStatuses[index - 1], @@ -57,8 +57,8 @@ export const CustomStatusSettings: React.FC = ({ }; 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 + 1], currentStatuses[index], @@ -89,7 +89,7 @@ export const CustomStatusSettings: React.FC = ({ vertical >
- {settings.customStatuses.length === 0 ? ( + {(settings.customStatuses || []).length === 0 ? (

No custom statuses yet. Click "Add Status" below @@ -97,7 +97,7 @@ export const CustomStatusSettings: React.FC = ({

) : ( - settings.customStatuses.map((status, index) => ( + (settings.customStatuses || []).map((status, index) => ( = ({ onMoveDown={moveCustomStatusDown} canMoveUp={index > 0} canMoveDown={ - index < settings.customStatuses.length - 1 + index < + (settings.customStatuses || []).length - 1 } /> )) diff --git a/components/SettingsUI/SynchronizationSettings.tsx b/components/SettingsUI/SynchronizationSettings.tsx index fb300b3..52f8528 100644 --- a/components/SettingsUI/SynchronizationSettings.tsx +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -23,7 +23,7 @@ export const SynchronizationSettings: React.FC = ({ }; const toggleGroup = (group: SyncGroup) => { - const currentGroups = [...settings.syncGroups]; + const currentGroups = [...(settings.syncGroups || [])]; const index = currentGroups.indexOf(group); if (index === -1) { currentGroups.push(group); @@ -36,24 +36,39 @@ export const SynchronizationSettings: React.FC = ({ const syncGroups: { id: SyncGroup; label: string; description: string }[] = [ { - id: "statuses", - label: "Statuses & Templates", - description: "Custom statuses, templates, and workflow rules.", + id: "templates", + label: "Templates", + description: "Status templates and their enabled status.", }, { - id: "appearance", - label: "Appearance", - description: "Colors, icons, and UI element styles.", + id: "customStatuses", + label: "Custom Statuses", + description: "Standalone custom statuses.", }, { - id: "behavior", - label: "Behavior & Storage", - description: "Frontmatter keys, mappings, and general logic.", + 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: "Commands & Features", - description: "Quick commands and experimental feature toggles.", + label: "Features & Limits", + description: "Quick commands, experiments, and vault limits.", }, ]; @@ -61,103 +76,164 @@ export const SynchronizationSettings: React.FC = ({

Multi-device Synchronization

- Keep your plugin settings in sync across devices using a file in - your vault. + Keep your plugin settings and data in sync across devices using + files in your vault.

- - - onChange("enableExternalStatusSync", e.target.checked) - } - /> - - - - - onChange("externalStatusSyncPath", e.target.value) - } - placeholder="_note-status-sync.json" - /> - - -
-

Selective Synchronization

+
+

Settings Synchronization

- Choose which groups of settings should be included in the - synchronization. + Save plugin configuration to a JSON file.

-
- {syncGroups.map((group) => ( -
toggleGroup(group.id)} - > -
- {}} // Handled by parent div onClick - style={{ marginRight: "8px" }} - /> - {group.label} -
+ + onChange( + "enableExternalStatusSync", + e.target.checked, + ) + } + /> + + + + + onChange("externalStatusSyncPath", e.target.value) + } + placeholder="_note-status-sync.json" + /> + + +
+
Selective Synchronization
+

+ Choose which groups of settings should be included in + the synchronization. +

+
+ {syncGroups.map((group) => (
toggleGroup(group.id)} > - {group.description} +
+ {}} // Handled by parent div onClick + style={{ marginRight: "8px" }} + /> + {group.label} +
+
+ {group.description} +
-
- ))} + ))} +
+
+ +
+ +
-
- - +
+

Non-Markdown Data Synchronization

+

+ Statuses for non-Markdown files (PDFs, images, etc.) are + stored in a special data file. Enable this to keep them in + sync across devices. +

+ + + + onChange("enableNonMarkdownSync", e.target.checked) + } + /> + + + + + onChange("nonMarkdownSyncPath", e.target.value) + } + placeholder="_note-status-data.json" + /> +
); diff --git a/components/SettingsUI/TemplateSettings.tsx b/components/SettingsUI/TemplateSettings.tsx index 4d9788d..99bd9a8 100644 --- a/components/SettingsUI/TemplateSettings.tsx +++ b/components/SettingsUI/TemplateSettings.tsx @@ -27,7 +27,7 @@ export const TemplateSettings: React.FC = ({ const handleTemplateToggle = useCallback( (templateId: string, enabled: boolean) => { - let newEnabledTemplates = [...settings.enabledTemplates]; + let newEnabledTemplates = [...(settings.enabledTemplates || [])]; if (enabled) { if (!newEnabledTemplates.includes(templateId)) { newEnabledTemplates.push(templateId); @@ -176,13 +176,13 @@ export const TemplateSettings: React.FC = ({
- {settings.templates.map((template) => ( + {(settings.templates || []).map((template) => ( = { - statuses: [ - "templates", - "customStatuses", - "enabledTemplates", - "useCustomStatusesOnly", - "useMultipleStatuses", - "singleStatusStorageMode", - "strictStatuses", - ], - appearance: [ + templates: ["templates", "enabledTemplates"], + customStatuses: ["customStatuses"], + statusColors: ["statusColors"], + uiAppearance: [ "fileExplorerIconPosition", "fileExplorerIconFrame", "fileExplorerIconColorMode", - "statusColors", "showStatusBar", "autoHideStatusBar", "statusBarShowTemplateName", @@ -40,19 +33,25 @@ export const SETTINGS_GROUPS: Record = { "editorToolbarButtonPosition", "editorToolbarButtonDisplay", ], - behavior: [ + workflow: [ + "useCustomStatusesOnly", + "useMultipleStatuses", + "singleStatusStorageMode", + "strictStatuses", + "enableStatusOverviewPopup", + ], + storage: [ "tagPrefix", "statusFrontmatterMappings", "writeMappedTagsToDefault", "applyStatusRecursivelyToSubfolders", - "vaultSizeLimit", - "enableStatusOverviewPopup", ], features: [ "quickStatusCommands", "enableExperimentalFeatures", "enableStatusDashboard", "enableGroupedStatusView", + "vaultSizeLimit", ], }; @@ -140,8 +139,11 @@ class SettingsService { // Filter settings based on selected groups const keysToSync = new Set(); - this.settings.syncGroups.forEach((group) => { - SETTINGS_GROUPS[group].forEach((key) => keysToSync.add(key)); + (this.settings.syncGroups || []).forEach((group) => { + const groupKeys = SETTINGS_GROUPS[group]; + if (groupKeys) { + groupKeys.forEach((key) => keysToSync.add(key)); + } }); const filteredSettings: Partial = {}; @@ -177,8 +179,11 @@ class SettingsService { // Determine which keys we should actually apply based on CURRENT settings const allowedKeys = new Set(); - this.settings.syncGroups.forEach((group) => { - SETTINGS_GROUPS[group].forEach((key) => allowedKeys.add(key)); + (this.settings.syncGroups || []).forEach((group) => { + const groupKeys = SETTINGS_GROUPS[group]; + if (groupKeys) { + groupKeys.forEach((key) => allowedKeys.add(key)); + } }); let hasChanged = false; diff --git a/core/statusStores/nonMarkdownStatusStore.ts b/core/statusStores/nonMarkdownStatusStore.ts index 7c01531..81661fb 100644 --- a/core/statusStores/nonMarkdownStatusStore.ts +++ b/core/statusStores/nonMarkdownStatusStore.ts @@ -1,5 +1,6 @@ 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>; @@ -11,19 +12,58 @@ type PersistedData = { export class NonMarkdownStatusStore implements StatusStore { private data: FileStatusMap = {}; - private readonly dataPath: string; + private isWatcherRegistered = false; - constructor(private readonly plugin: Plugin) { - const configDir = this.plugin.app.vault.configDir; - const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`; - this.dataPath = normalizePath( - `${pluginDir}/non-markdown-statuses.json`, - ); - } + constructor(private readonly plugin: Plugin) {} async initialize(): Promise { 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 normalizePath(settings.nonMarkdownSyncPath); + } + + const configDir = this.plugin.app.vault.configDir; + const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`; + return normalizePath(`${pluginDir}/non-markdown-statuses.json`); } canHandle(file: TFile): boolean { @@ -70,14 +110,18 @@ export class NonMarkdownStatusStore implements StatusStore { } private async loadFromDisk(): Promise { + const path = this.getDataPath(); try { - const raw = await this.plugin.app.vault.adapter.read(this.dataPath); + 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 { - // File may not exist yet or be malformed; start with empty dataset. + } catch (e) { + console.error("Failed to load non-markdown statuses:", e); } return {}; } @@ -87,20 +131,58 @@ export class NonMarkdownStatusStore implements StatusStore { version: 1, files: this.data, }; - await this.ensureDirectoryExists(); + const path = this.getDataPath(); + await this.ensureDirectoryExists(path); await this.plugin.app.vault.adapter.write( - this.dataPath, + path, JSON.stringify(payload, null, 2), ); } - private async ensureDirectoryExists(): Promise { - const dir = this.dataPath.split("/").slice(0, -1).join("/"); + private async ensureDirectoryExists(path: string): Promise { + 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) => { diff --git a/main.tsx b/main.tsx index 2ea5ed9..aab14ed 100644 --- a/main.tsx +++ b/main.tsx @@ -32,8 +32,8 @@ export default class NoteStatusPlugin extends Plugin { async onload() { BaseNoteStatusService.initialize(this.app); - await statusStoreManager.initialize(this); await this.loadPluginSettings(); + await statusStoreManager.initialize(this); // INFO: initialize all integrations Promise.all([ diff --git a/types/pluginSettings.ts b/types/pluginSettings.ts index fc25208..619552e 100644 --- a/types/pluginSettings.ts +++ b/types/pluginSettings.ts @@ -22,7 +22,14 @@ export interface StatusTemplate { statuses: NoteStatus[]; } -export type SyncGroup = "statuses" | "appearance" | "behavior" | "features"; +export type SyncGroup = + | "templates" + | "customStatuses" + | "statusColors" + | "uiAppearance" + | "workflow" + | "storage" + | "features"; export type PluginSettings = { fileExplorerIconPosition: @@ -75,5 +82,7 @@ export type PluginSettings = { enableExternalStatusSync: boolean; // Whether to sync statuses to an external file externalStatusSyncPath: string; // Path to the external sync file syncGroups: SyncGroup[]; // Selected groups of settings to synchronize + enableNonMarkdownSync: boolean; // Whether to sync non-markdown statuses to a vault file + nonMarkdownSyncPath: string; // Path to the non-markdown sync file [key: string]: unknown; }; From 32af8e1c78bb60035e9420c348dd84f6fb0668ff Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 12:43:18 +0100 Subject: [PATCH 5/5] 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 --- .../SettingsUI/SynchronizationSettings.tsx | 31 ++++++++++++ core/statusStoreManager.ts | 14 ++++-- core/statusStores/nonMarkdownStatusStore.ts | 47 ++++++++++++++++++- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/components/SettingsUI/SynchronizationSettings.tsx b/components/SettingsUI/SynchronizationSettings.tsx index 52f8528..c1b2a49 100644 --- a/components/SettingsUI/SynchronizationSettings.tsx +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -2,6 +2,7 @@ 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; @@ -22,6 +23,24 @@ export const SynchronizationSettings: React.FC = ({ 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); @@ -234,6 +253,18 @@ export const SynchronizationSettings: React.FC = ({ placeholder="_note-status-data.json" /> + +
+ + +
); diff --git a/core/statusStoreManager.ts b/core/statusStoreManager.ts index 917b580..c2f581f 100644 --- a/core/statusStoreManager.ts +++ b/core/statusStoreManager.ts @@ -5,14 +5,22 @@ 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]; - const nonMarkdownStore = new NonMarkdownStatusStore(plugin); - await nonMarkdownStore.initialize(); - this.registerStore(nonMarkdownStore); + 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) { diff --git a/core/statusStores/nonMarkdownStatusStore.ts b/core/statusStores/nonMarkdownStatusStore.ts index 81661fb..be8099e 100644 --- a/core/statusStores/nonMarkdownStatusStore.ts +++ b/core/statusStores/nonMarkdownStatusStore.ts @@ -58,14 +58,59 @@ export class NonMarkdownStatusStore implements StatusStore { settings.enableNonMarkdownSync && settings.nonMarkdownSyncPath ) { - return normalizePath(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 { + 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 { + 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"; }