mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
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:
parent
cbb9c0f531
commit
2fe31700f9
5 changed files with 215 additions and 6 deletions
73
components/SettingsUI/SynchronizationSettings.tsx
Normal file
73
components/SettingsUI/SynchronizationSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -55,4 +55,6 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
|
|||
applyStatusRecursivelyToSubfolders: false,
|
||||
statusFrontmatterMappings: [],
|
||||
writeMappedTagsToDefault: false,
|
||||
enableExternalStatusSync: false,
|
||||
externalStatusSyncPath: "_note-status-sync.json",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue