From 2fe31700f9c8463ab534b78fe402cf84df4fe000 Mon Sep 17 00:00:00 2001 From: Aleix Soler Date: Wed, 18 Mar 2026 11:32:38 +0100 Subject: [PATCH] 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; };