feat: implement granular selective synchronization and non-Markdown status sync

- Expand sync categories to include templates, custom statuses, colors, UI, workflow, storage, and features
- Add support for syncing non-Markdown status data to a vault-based JSON file
- Update NonMarkdownStatusStore to handle vault-based storage and real-time updates
- Enhance Synchronization UI with granular group selection and data sync options
- Add safety checks to prevent TypeErrors during settings initialization and migration
This commit is contained in:
Aleix Soler 2026-03-18 12:35:49 +01:00
parent e5c456b0aa
commit f97bca5fa9
8 changed files with 331 additions and 148 deletions

View file

@ -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
}
/>
))

View file

@ -23,7 +23,7 @@ export const SynchronizationSettings: React.FC<Props> = ({
};
const toggleGroup = (group: SyncGroup) => {
const currentGroups = [...settings.syncGroups];
const currentGroups = [...(settings.syncGroups || [])];
const index = currentGroups.indexOf(group);
if (index === -1) {
currentGroups.push(group);
@ -36,24 +36,39 @@ export const SynchronizationSettings: React.FC<Props> = ({
const syncGroups: { id: SyncGroup; label: string; description: string }[] =
[
{
id: "statuses",
label: "Statuses & Templates",
description: "Custom statuses, templates, and workflow rules.",
id: "templates",
label: "Templates",
description: "Status templates and their enabled status.",
},
{
id: "appearance",
label: "Appearance",
description: "Colors, icons, and UI element styles.",
id: "customStatuses",
label: "Custom Statuses",
description: "Standalone custom statuses.",
},
{
id: "behavior",
label: "Behavior & Storage",
description: "Frontmatter keys, mappings, and general logic.",
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: "Commands & Features",
description: "Quick commands and experimental feature toggles.",
label: "Features & Limits",
description: "Quick commands, experiments, and vault limits.",
},
];
@ -61,103 +76,164 @@ export const SynchronizationSettings: React.FC<Props> = ({
<div>
<h3>Multi-device Synchronization</h3>
<p>
Keep your plugin settings in sync across devices using a file in
your vault.
Keep your plugin settings and data in sync across devices using
files in your vault.
</p>
<SettingItem
name="Enable external 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="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" }}>
<h4>Selective Synchronization</h4>
<div className="sync-section" style={{ marginBottom: "30px" }}>
<h4>Settings Synchronization</h4>
<p className="setting-item-description">
Choose which groups of settings should be included in the
synchronization.
Save plugin configuration to a JSON file.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "10px",
marginTop: "10px",
}}
<SettingItem
name="Enable settings synchronization"
description="Automatically save and load selected plugin settings from a file in your vault."
>
{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>
<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
className="setting-item-description"
style={{ marginTop: "4px", fontSize: "0.85em" }}
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)}
>
{group.description}
<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>
<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="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 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>
</div>
);

View file

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

View file

@ -57,5 +57,15 @@ export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
writeMappedTagsToDefault: false,
enableExternalStatusSync: false,
externalStatusSyncPath: "_note-status-sync.json",
syncGroups: ["statuses", "appearance", "behavior", "features"],
syncGroups: [
"templates",
"customStatuses",
"statusColors",
"uiAppearance",
"workflow",
"storage",
"features",
],
enableNonMarkdownSync: false,
nonMarkdownSyncPath: "_note-status-data.json",
};

View file

@ -4,20 +4,13 @@ import { Plugin, TFile, normalizePath } from "obsidian";
import eventBus from "./eventBus";
export const SETTINGS_GROUPS: Record<SyncGroup, (keyof PluginSettings)[]> = {
statuses: [
"templates",
"customStatuses",
"enabledTemplates",
"useCustomStatusesOnly",
"useMultipleStatuses",
"singleStatusStorageMode",
"strictStatuses",
],
appearance: [
templates: ["templates", "enabledTemplates"],
customStatuses: ["customStatuses"],
statusColors: ["statusColors"],
uiAppearance: [
"fileExplorerIconPosition",
"fileExplorerIconFrame",
"fileExplorerIconColorMode",
"statusColors",
"showStatusBar",
"autoHideStatusBar",
"statusBarShowTemplateName",
@ -40,19 +33,25 @@ export const SETTINGS_GROUPS: Record<SyncGroup, (keyof PluginSettings)[]> = {
"editorToolbarButtonPosition",
"editorToolbarButtonDisplay",
],
behavior: [
workflow: [
"useCustomStatusesOnly",
"useMultipleStatuses",
"singleStatusStorageMode",
"strictStatuses",
"enableStatusOverviewPopup",
],
storage: [
"tagPrefix",
"statusFrontmatterMappings",
"writeMappedTagsToDefault",
"applyStatusRecursivelyToSubfolders",
"vaultSizeLimit",
"enableStatusOverviewPopup",
],
features: [
"quickStatusCommands",
"enableExperimentalFeatures",
"enableStatusDashboard",
"enableGroupedStatusView",
"vaultSizeLimit",
],
};
@ -140,8 +139,11 @@ class SettingsService {
// Filter settings based on selected groups
const keysToSync = new Set<keyof PluginSettings>();
this.settings.syncGroups.forEach((group) => {
SETTINGS_GROUPS[group].forEach((key) => keysToSync.add(key));
(this.settings.syncGroups || []).forEach((group) => {
const groupKeys = SETTINGS_GROUPS[group];
if (groupKeys) {
groupKeys.forEach((key) => keysToSync.add(key));
}
});
const filteredSettings: Partial<PluginSettings> = {};
@ -177,8 +179,11 @@ class SettingsService {
// Determine which keys we should actually apply based on CURRENT settings
const allowedKeys = new Set<keyof PluginSettings>();
this.settings.syncGroups.forEach((group) => {
SETTINGS_GROUPS[group].forEach((key) => allowedKeys.add(key));
(this.settings.syncGroups || []).forEach((group) => {
const groupKeys = SETTINGS_GROUPS[group];
if (groupKeys) {
groupKeys.forEach((key) => allowedKeys.add(key));
}
});
let hasChanged = false;

View file

@ -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,58 @@ 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 normalizePath(settings.nonMarkdownSyncPath);
}
const configDir = this.plugin.app.vault.configDir;
const pluginDir = `${configDir}/plugins/${this.plugin.manifest.id}`;
return normalizePath(`${pluginDir}/non-markdown-statuses.json`);
}
canHandle(file: TFile): boolean {
@ -70,14 +110,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 +131,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) => {

View file

@ -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([

View file

@ -22,7 +22,14 @@ export interface StatusTemplate {
statuses: NoteStatus[];
}
export type SyncGroup = "statuses" | "appearance" | "behavior" | "features";
export type SyncGroup =
| "templates"
| "customStatuses"
| "statusColors"
| "uiAppearance"
| "workflow"
| "storage"
| "features";
export type PluginSettings = {
fileExplorerIconPosition:
@ -75,5 +82,7 @@ export type PluginSettings = {
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;
};