mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 05:45:04 +00:00
Merge pull request #90 from devonthesofa/feature/syncronize-settings
feat: implement multi-device synchronization for plugin settings
This commit is contained in:
commit
07c8b8d4df
10 changed files with 684 additions and 40 deletions
|
|
@ -16,7 +16,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const addNewCustomStatus = () => {
|
||||
const currentStatuses = [...settings.customStatuses];
|
||||
const currentStatuses = [...(settings.customStatuses || [])];
|
||||
currentStatuses.push({
|
||||
name: "",
|
||||
icon: "",
|
||||
|
|
@ -27,7 +27,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
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<Props> = ({
|
|||
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<Props> = ({
|
|||
|
||||
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<Props> = ({
|
|||
};
|
||||
|
||||
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<Props> = ({
|
|||
vertical
|
||||
>
|
||||
<div className="custom-status-list">
|
||||
{settings.customStatuses.length === 0 ? (
|
||||
{(settings.customStatuses || []).length === 0 ? (
|
||||
<div className="custom-status-list__empty">
|
||||
<p>
|
||||
No custom statuses yet. Click "Add Status" below
|
||||
|
|
@ -97,7 +97,7 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
settings.customStatuses.map((status, index) => (
|
||||
(settings.customStatuses || []).map((status, index) => (
|
||||
<CustomStatusItem
|
||||
key={index}
|
||||
status={status}
|
||||
|
|
@ -108,7 +108,8 @@ export const CustomStatusSettings: React.FC<Props> = ({
|
|||
onMoveDown={moveCustomStatusDown}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={
|
||||
index < settings.customStatuses.length - 1
|
||||
index <
|
||||
(settings.customStatuses || []).length - 1
|
||||
}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
271
components/SettingsUI/SynchronizationSettings.tsx
Normal file
271
components/SettingsUI/SynchronizationSettings.tsx
Normal file
|
|
@ -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<Props> = ({
|
||||
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 (
|
||||
<div>
|
||||
<h3>Multi-device Synchronization</h3>
|
||||
<p>
|
||||
Keep your plugin settings and data in sync across devices using
|
||||
files in your vault.
|
||||
</p>
|
||||
|
||||
<div className="sync-section" style={{ marginBottom: "30px" }}>
|
||||
<h4>Settings Synchronization</h4>
|
||||
<p className="setting-item-description">
|
||||
Save plugin configuration to a JSON file.
|
||||
</p>
|
||||
|
||||
<SettingItem
|
||||
name="Enable settings synchronization"
|
||||
description="Automatically save and load selected 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="Settings 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="sync-groups-container"
|
||||
style={{ margin: "20px 0" }}
|
||||
>
|
||||
<h5>Selective Synchronization</h5>
|
||||
<p className="setting-item-description">
|
||||
Choose which groups of settings should be included in
|
||||
the synchronization.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "10px",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
{syncGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`selectable-group-item ${
|
||||
(settings.syncGroups || []).includes(
|
||||
group.id,
|
||||
)
|
||||
? "is-selected"
|
||||
: ""
|
||||
}`}
|
||||
style={{
|
||||
padding: "10px",
|
||||
border: "1px solid var(--background-modifier-border)",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: (
|
||||
settings.syncGroups || []
|
||||
).includes(group.id)
|
||||
? "var(--background-modifier-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<div style={{ fontWeight: "bold" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(
|
||||
settings.syncGroups || []
|
||||
).includes(group.id)}
|
||||
onChange={() => {}} // Handled by parent div onClick
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
{group.label}
|
||||
</div>
|
||||
<div
|
||||
className="setting-item-description"
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
fontSize: "0.85em",
|
||||
}}
|
||||
>
|
||||
{group.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="note-status-settings__actions"
|
||||
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
|
||||
>
|
||||
<button onClick={handleExport}>
|
||||
📤 Export selected now
|
||||
</button>
|
||||
<button onClick={handleImport}>
|
||||
📥 Import selected now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sync-section">
|
||||
<h4>Non-Markdown Data Synchronization</h4>
|
||||
<p className="setting-item-description">
|
||||
Statuses for non-Markdown files (PDFs, images, etc.) are
|
||||
stored in a special data file. Enable this to keep them in
|
||||
sync across devices.
|
||||
</p>
|
||||
|
||||
<SettingItem
|
||||
name="Sync non-Markdown statuses"
|
||||
description="Store non-Markdown status data in a vault file instead of the internal plugin folder."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableNonMarkdownSync || false}
|
||||
onChange={(e) =>
|
||||
onChange("enableNonMarkdownSync", e.target.checked)
|
||||
}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
name="Data sync file path"
|
||||
description="Path to the JSON file where non-Markdown statuses will be stored."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
settings.nonMarkdownSyncPath ||
|
||||
"_note-status-data.json"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("nonMarkdownSyncPath", e.target.value)
|
||||
}
|
||||
placeholder="_note-status-data.json"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<div
|
||||
className="note-status-settings__actions"
|
||||
style={{ marginTop: "1em", display: "flex", gap: "10px" }}
|
||||
>
|
||||
<button onClick={handleDataExport}>
|
||||
📤 Export internal data to vault
|
||||
</button>
|
||||
<button onClick={handleDataImport}>
|
||||
📥 Import data from vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -27,7 +27,7 @@ export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
|
|||
|
||||
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<TemplateSettingsProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
<div className="template-list">
|
||||
{settings.templates.map((template) => (
|
||||
{(settings.templates || []).map((template) => (
|
||||
<TemplateItem
|
||||
key={template.id}
|
||||
template={template}
|
||||
isEnabled={settings.enabledTemplates.includes(
|
||||
template.id,
|
||||
)}
|
||||
isEnabled={(
|
||||
settings.enabledTemplates || []
|
||||
).includes(template.id)}
|
||||
onToggle={handleTemplateToggle}
|
||||
onEdit={handleEditTemplate}
|
||||
onDelete={handleDeleteTemplate}
|
||||
|
|
|
|||
|
|
@ -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,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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<SyncGroup, (keyof PluginSettings)[]> = {
|
||||
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<void> {
|
||||
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<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(force = false) {
|
||||
if (!this.settings.enableExternalStatusSync && !force) return;
|
||||
|
||||
const path = normalizePath(this.settings.externalStatusSyncPath);
|
||||
|
||||
// Filter settings based on selected groups
|
||||
const keysToSync = new Set<keyof PluginSettings>();
|
||||
(this.settings.syncGroups || []).forEach((group) => {
|
||||
const groupKeys = SETTINGS_GROUPS[group];
|
||||
if (groupKeys) {
|
||||
groupKeys.forEach((key) => keysToSync.add(key));
|
||||
}
|
||||
});
|
||||
|
||||
const filteredSettings: Partial<PluginSettings> = {};
|
||||
keysToSync.forEach((key) => {
|
||||
(filteredSettings as Record<string, unknown>)[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<keyof PluginSettings>();
|
||||
(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<keyof PluginSettings>;
|
||||
|
||||
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<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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, Record<string, string[]>>;
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<FileStatusMap> {
|
||||
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<void> {
|
||||
const dir = this.dataPath.split("/").slice(0, -1).join("/");
|
||||
private async ensureDirectoryExists(path: string): Promise<void> {
|
||||
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) => {
|
||||
|
|
|
|||
2
main.tsx
2
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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue