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 new file mode 100644 index 0000000..c1b2a49 --- /dev/null +++ b/components/SettingsUI/SynchronizationSettings.tsx @@ -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 = ({ + 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 ( +
+

Multi-device Synchronization

+

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

+ +
+

Settings Synchronization

+

+ Save plugin configuration to a JSON file. +

+ + + + 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)} + > +
+ {}} // 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) => ( = ({ 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..608fd1c 100644 --- a/constants/defaultSettings.ts +++ b/constants/defaultSettings.ts @@ -55,4 +55,17 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { 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", }; diff --git a/core/settingsService.ts b/core/settingsService.ts index 068ef4a..3d5a880 100644 --- a/core/settingsService.ts +++ b/core/settingsService.ts @@ -1,17 +1,75 @@ import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings"; -import { PluginSettings } from "@/types/pluginSettings"; -import { Plugin } from "obsidian"; +import { PluginSettings, SyncGroup } from "@/types/pluginSettings"; +import { Plugin, TFile, normalizePath } from "obsidian"; import eventBus from "./eventBus"; +export const SETTINGS_GROUPS: Record = { + 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", + ], + storage: [ + "tagPrefix", + "statusFrontmatterMappings", + "writeMappedTagsToDefault", + "applyStatusRecursivelyToSubfolders", + ], + features: [ + "quickStatusCommands", + "enableExperimentalFeatures", + "enableStatusDashboard", + "enableGroupedStatusView", + "vaultSizeLimit", + ], +}; + class SettingsService { private plugin: Plugin; public settings: PluginSettings; + private isWatcherRegistered = false; constructor() {} async initialize(plugin: Plugin): Promise { this.plugin = plugin; await this.loadSettings(); + + if (this.settings.enableExternalStatusSync) { + await this.loadFromExternalFile(); + this.setupExternalFileWatcher(); + } } setValue(key: keyof PluginSettings, value: unknown) { @@ -21,6 +79,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 +101,142 @@ 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(force = false) { + 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) => { + const groupKeys = SETTINGS_GROUPS[group]; + if (groupKeys) { + groupKeys.forEach((key) => keysToSync.add(key)); + } + }); + + const filteredSettings: Partial = {}; + keysToSync.forEach((key) => { + (filteredSettings as Record)[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(); + (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; + + 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)[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/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 7c01531..be8099e 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,103 @@ 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 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 { @@ -70,14 +155,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 +176,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 8af2769..619552e 100644 --- a/types/pluginSettings.ts +++ b/types/pluginSettings.ts @@ -22,6 +22,15 @@ export interface StatusTemplate { statuses: NoteStatus[]; } +export type SyncGroup = + | "templates" + | "customStatuses" + | "statusColors" + | "uiAppearance" + | "workflow" + | "storage" + | "features"; + export type PluginSettings = { fileExplorerIconPosition: | "absolute-right" @@ -70,5 +79,10 @@ 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 + 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; };