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
This commit is contained in:
Aleix Soler 2026-03-18 11:32:38 +01:00
parent cbb9c0f531
commit 2fe31700f9
5 changed files with 215 additions and 6 deletions

View file

@ -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<Props> = ({
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 (
<div>
<h3>Multi-device Synchronization</h3>
<p>
Keep all your plugin settings (statuses, templates, UI
preferences, etc.) in sync across devices using a file in your
vault.
</p>
<SettingItem
name="Enable external synchronization"
description="Automatically save and load all 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="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="note-status-settings__actions"
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
>
<button onClick={handleExport}>📤 Export to file now</button>
<button onClick={handleImport}>📥 Import from file now</button>
</div>
</div>
);
};

View file

@ -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<Props> = ({ settings, onChange }) => {
/>
),
},
{
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",

View file

@ -55,4 +55,6 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
applyStatusRecursivelyToSubfolders: false,
statusFrontmatterMappings: [],
writeMappedTagsToDefault: false,
enableExternalStatusSync: false,
externalStatusSyncPath: "_note-status-sync.json",
};

View file

@ -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<void> {
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<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() {
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<keyof PluginSettings>;
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<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();

View file

@ -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;
};