From 044b01031d8becfa255043ec4284bee60b093799 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Fri, 17 Jul 2026 16:38:39 +0100 Subject: [PATCH 1/8] feat: add ignore patterns for files and folders we do not want to sync --- src/ignore.test.ts | 181 +++++++++++++++++++++++++++++++++++++++++++ src/ignore.ts | 55 +++++++++++++ src/main.ts | 58 +++++++++++--- src/settings-tab.ts | 86 +++++++++++++++++--- src/settings.test.ts | 32 ++++++++ src/settings.ts | 38 ++++++++- src/vault-adapter.ts | 29 +++++-- 7 files changed, 448 insertions(+), 31 deletions(-) create mode 100644 src/ignore.test.ts create mode 100644 src/ignore.ts diff --git a/src/ignore.test.ts b/src/ignore.test.ts new file mode 100644 index 0000000..8074a44 --- /dev/null +++ b/src/ignore.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { hasLocalPrefix, matchesGlob, shouldIgnore } from "./ignore.ts"; + +const hasLocalPrefixCases: { name: string; path: string; want: boolean }[] = [ + { name: "top-level local_ folder", path: "local_notes/note.md", want: true }, + { name: "nested local_ folder", path: "a/b/local_notes/note.md", want: true }, + { name: "local_ file at root", path: "local_draft.md", want: true }, + { name: "local_ file nested", path: "docs/local_draft.md", want: true }, + { name: "no match", path: "notes/note.md", want: false }, + { name: "partial prefix match", path: "local_not/stuff.md", want: true }, + { + name: "local_ in middle of segment", + path: "not_local_/file.md", + want: false, + }, + { name: "empty path", path: "", want: false }, +]; + +for (const { name, path, want } of hasLocalPrefixCases) { + test(`hasLocalPrefix: ${name}`, () => { + assert.strictEqual(hasLocalPrefix(path), want); + }); +} + +const matchesGlobCases: { + name: string; + path: string; + pattern: string; + want: boolean; +}[] = [ + { + name: "exact match", + path: "notes/note.md", + pattern: "notes/note.md", + want: true, + }, + { + name: "exact mismatch", + path: "notes/note.md", + pattern: "other/note.md", + want: false, + }, + { + name: "star matches within segment", + path: "docs/file.md", + pattern: "docs/*.md", + want: true, + }, + { + name: "star does not cross segments", + path: "a/b/file.md", + pattern: "a/*.md", + want: false, + }, + { + name: "star matches any extension", + path: "docs/file.txt", + pattern: "docs/*", + want: true, + }, + { + name: "double star at start", + path: "a/b/c.md", + pattern: "**/*.md", + want: true, + }, + { name: "double star at end", path: "a/b/c.md", pattern: "a/**", want: true }, + { + name: "double star matches empty segments", + path: "a.md", + pattern: "**/a.md", + want: true, + }, + { + name: "double star in middle", + path: "a/b/c/d.md", + pattern: "a/**/d.md", + want: true, + }, + { + name: "question mark matches one char", + path: "file1.md", + pattern: "file?.md", + want: true, + }, + { + name: "question mark does not match slash", + path: "a/b", + pattern: "a?b", + want: false, + }, + { + name: "question mark fails on two chars", + path: "file12.md", + pattern: "file?.md", + want: false, + }, + { + name: "leading slash ignored", + path: "notes/note.md", + pattern: "/notes/note.md", + want: true, + }, + { + name: "trailing slash pattern", + path: "docs", + pattern: "docs/", + want: false, + }, + { + name: "complex pattern", + path: "src/components/Button.tsx", + pattern: "src/**/*.tsx", + want: true, + }, + { + name: "no match", + path: "notes/note.md", + pattern: "docs/*.md", + want: false, + }, + { name: "empty pattern", path: "notes/note.md", pattern: "", want: false }, + { name: "empty path with star pattern", path: "", pattern: "*", want: true }, +]; + +for (const { name, path, pattern, want } of matchesGlobCases) { + test(`matchesGlob: ${name}`, () => { + assert.strictEqual(matchesGlob(path, pattern), want); + }); +} + +const shouldIgnoreCases: { + name: string; + path: string; + patterns: string[]; + want: boolean; +}[] = [ + { + name: "local_ prefix ignored", + path: "local_notes/note.md", + patterns: [], + want: true, + }, + { + name: "glob pattern ignored", + path: "private/secret.md", + patterns: ["private/**"], + want: true, + }, + { + name: "no match with patterns", + path: "notes/note.md", + patterns: ["private/**"], + want: false, + }, + { + name: "empty patterns no match", + path: "notes/note.md", + patterns: [], + want: false, + }, + { + name: "multiple patterns second match", + path: "temp/file.md", + patterns: ["private/**", "temp/*"], + want: true, + }, + { + name: "local_ takes precedence", + path: "local_x/file.md", + patterns: [], + want: true, + }, +]; + +for (const { name, path, patterns, want } of shouldIgnoreCases) { + test(`shouldIgnore: ${name}`, () => { + assert.strictEqual(shouldIgnore(path, patterns), want); + }); +} diff --git a/src/ignore.ts b/src/ignore.ts new file mode 100644 index 0000000..14002b0 --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,55 @@ +// hasLocalPrefix reports whether any segment of path starts with "local_", the built-in +// convention for vault content that should never be synced. +export function hasLocalPrefix(path: string): boolean { + const segments = path.split("/"); + for (const segment of segments) { + if (segment.startsWith("local_")) { + return true; + } + } + return false; +} + +// globToRegex converts a simplified glob pattern to a regular expression. Supported syntax: +// * โ€” matches any characters within one path segment (stops at /) +// ** โ€” matches zero or more path segments +// ? โ€” matches exactly one character (not /) +// All other characters match literally. A leading / in the pattern is stripped. +function globToRegex(pattern: string): RegExp { + const pat = pattern.startsWith("/") ? pattern.slice(1) : pattern; + const body = pat.replace(/\*\*\/|\*\*|\*|\?|[.+^${}()|[\]\\]/g, (token) => { + switch (token) { + case "**/": + return "(.*/)?"; + case "**": + return ".*"; + case "*": + return "[^/]*"; + case "?": + return "[^/]"; + default: + return `\\${token}`; // escaped regex metachar + } + }); + return new RegExp(`^${body}$`); +} + +// matchesGlob reports whether path matches a simplified glob pattern. +export function matchesGlob(path: string, pattern: string): boolean { + return globToRegex(pattern).test(path); +} + +// shouldIgnore reports whether path should be excluded from sync, checking the built-in local_ +// prefix convention first, then any user-configured glob patterns. +export function shouldIgnore(path: string, patterns: string[]): boolean { + if (hasLocalPrefix(path)) { + return true; + } + for (const pattern of patterns) { + if (matchesGlob(path, pattern)) { + return true; + } + } + + return false; +} diff --git a/src/main.ts b/src/main.ts index 0100074..098a51e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,16 @@ import { Plugin } from "obsidian"; import { createLogger, type Logger, type LogSink } from "./log"; import { createLogSink } from "./log-adapter"; import { GeodeLogView, LOG_VIEW_TYPE } from "./log-view"; -import { DEFAULT_SETTINGS, type GeodeSettings, normalizeSettings } from "./settings"; +import { + DEFAULT_SETTINGS, + type GeodeSettings, + normalizeSettings, +} from "./settings"; import { GeodeSettingTab } from "./settings-tab"; -import { createObsidianStateStore, createObsidianVaultReader } from "./vault-adapter"; +import { + createObsidianStateStore, + createObsidianVaultReader, +} from "./vault-adapter"; import { diffSnapshots, takeSnapshot } from "./vault-state"; // VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of @@ -24,7 +31,11 @@ const LOG_MIN_LEVEL = "debug"; // equivalent) so the Settings command can jump straight to Geode's tab, and opening the log view // can close the settings modal out from under itself. type AppWithSetting = App & { - setting: { open: () => void; close: () => void; openTabById: (id: string) => void }; + setting: { + open: () => void; + close: () => void; + openTabById: (id: string) => void; + }; }; // GeodePlugin is the Obsidian plugin entry point that owns settings load and save. @@ -38,10 +49,17 @@ export default class GeodePlugin extends Plugin { async onload() { await this.loadSettings(); - this.logSink = createLogSink(this.app.vault.adapter, this.manifest.dir, MAX_LOG_LINES); + this.logSink = createLogSink( + this.app.vault.adapter, + this.manifest.dir, + MAX_LOG_LINES, + ); this.logger = createLogger(this.logSink, LOG_MIN_LEVEL); - this.registerView(LOG_VIEW_TYPE, (leaf) => new GeodeLogView(leaf, this.logSink)); + this.registerView( + LOG_VIEW_TYPE, + (leaf) => new GeodeLogView(leaf, this.logSink), + ); this.addCommand({ id: "logs", name: "Logs", @@ -62,10 +80,18 @@ export default class GeodePlugin extends Plugin { this.app.workspace.onLayoutReady(() => { void this.refreshVaultState(); - this.registerEvent(this.app.vault.on("create", () => this.scheduleVaultStateRefresh())); - this.registerEvent(this.app.vault.on("modify", () => this.scheduleVaultStateRefresh())); - this.registerEvent(this.app.vault.on("delete", () => this.scheduleVaultStateRefresh())); - this.registerEvent(this.app.vault.on("rename", () => this.scheduleVaultStateRefresh())); + this.registerEvent( + this.app.vault.on("create", () => this.scheduleVaultStateRefresh()), + ); + this.registerEvent( + this.app.vault.on("modify", () => this.scheduleVaultStateRefresh()), + ); + this.registerEvent( + this.app.vault.on("delete", () => this.scheduleVaultStateRefresh()), + ); + this.registerEvent( + this.app.vault.on("rename", () => this.scheduleVaultStateRefresh()), + ); }); this.register(() => { @@ -131,14 +157,22 @@ export default class GeodePlugin extends Plugin { return; } - const store = createObsidianStateStore(this.app.vault.adapter, `${dir}/state.json`); - const reader = createObsidianVaultReader(this.app.vault); + const store = createObsidianStateStore( + this.app.vault.adapter, + `${dir}/state.json`, + ); + const reader = createObsidianVaultReader( + this.app.vault, + this.settings.ignorePatterns, + ); const previous = await store.read(); const current = await takeSnapshot(reader, previous); const changes = diffSnapshots(previous, current); - this.logger.info(`vault state refreshed (${changes.length} change(s) since last run)`); + this.logger.info( + `vault state refreshed (${changes.length} change(s) since last run)`, + ); await store.write(current); } } diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 78d12d7..ac3fe63 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -38,7 +38,9 @@ function renderHeader(containerEl: HTMLElement): void { window.open("https://github.com/8thpark/geode", "_blank"); }); new ButtonComponent(links).setButtonText("Support").onClick(() => { - const target = containerEl.querySelector(".geode-support-anchor"); + const target = containerEl.querySelector( + ".geode-support-anchor", + ); if (target !== null) { target.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -61,7 +63,10 @@ function onFieldChanged(tab: GeodeSettingTab): void { } // renderProviderFields draws the fields specific to the selected provider. -function renderProviderFields(tab: GeodeSettingTab, containerEl: HTMLElement): void { +function renderProviderFields( + tab: GeodeSettingTab, + containerEl: HTMLElement, +): void { if (tab.draft.provider === "r2") { new Setting(containerEl) .setName("Account ID") @@ -112,7 +117,9 @@ function renderProviderFields(tab: GeodeSettingTab, containerEl: HTMLElement): v function renderSecretRow(tab: GeodeSettingTab, containerEl: HTMLElement): void { new Setting(containerEl) .setName("Secret access key") - .setDesc("Stored in Obsidian's built in secret manager, never in plugin data or synced files.") + .setDesc( + "Stored in Obsidian's built in secret manager, never in plugin data or synced files.", + ) .addComponent((el) => { const component = new SecretComponent(tab.app, el) .setValue(tab.draft.secretId) @@ -155,7 +162,9 @@ function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { setting.nameEl.prepend(tab.statusDotEl); const statusLine = setting.descEl.createSpan({ cls: "geode-status-line" }); - tab.connectionMessageEl = statusLine.createSpan({ cls: "geode-connection-message" }); + tab.connectionMessageEl = statusLine.createSpan({ + cls: "geode-connection-message", + }); tab.statusSeparatorEl = statusLine.createSpan({ cls: "geode-status-separator", text: " ยท ", @@ -188,7 +197,10 @@ function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { } // renderStorageSection draws the card of storage related settings. -function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { +function renderStorageSection( + tab: GeodeSettingTab, + containerEl: HTMLElement, +): void { const card = containerEl.createDiv({ cls: "geode-card" }); new Setting(card) @@ -238,6 +250,37 @@ function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): v renderActions(tab, card); } +// renderSyncSection draws the sync exclusion settings: a textarea for user glob patterns and +// a note about the built-in local_ prefix convention. +function renderSyncSection( + tab: GeodeSettingTab, + containerEl: HTMLElement, +): void { + new Setting(containerEl).setName("Sync").setHeading(); + const card = containerEl.createDiv({ cls: "geode-card" }); + + new Setting(card) + .setName("Ignore patterns") + .setDesc( + "Glob patterns for files and folders to exclude from sync, one per line. " + + "The local_ prefix is always excluded regardless of these patterns.", + ) + .addTextArea((text) => { + text + .setPlaceholder("private/**\n*.tmp") + .setValue(tab.draft.ignorePatterns.join("\n")) + .onChange((value) => { + tab.draft.ignorePatterns = value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + onFieldChanged(tab); + }); + text.inputEl.rows = 5; + text.inputEl.style.width = "100%"; + }); +} + // platformLabel returns a short human readable name for the OS Obsidian is running on. function platformLabel(): string { if (Platform.isMacOS) { @@ -290,13 +333,20 @@ function debugInfoText(tab: GeodeSettingTab): string { } // flashButtonText sets a button's text to feedback, then reverts it to original after a delay. -function flashButtonText(button: ButtonComponent, original: string, feedback: string): void { +function flashButtonText( + button: ButtonComponent, + original: string, + feedback: string, +): void { button.setButtonText(feedback); window.setTimeout(() => button.setButtonText(original), 1500); } // renderSupportSection draws the Support heading and its card of docs, email, and debug info. -function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { +function renderSupportSection( + tab: GeodeSettingTab, + containerEl: HTMLElement, +): void { const heading = new Setting(containerEl).setName("Support").setHeading(); heading.settingEl.addClass("geode-support-anchor"); const card = containerEl.createDiv({ cls: "geode-card" }); @@ -338,7 +388,10 @@ function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): v .setName("Debugging info") .setDesc("Include this when you contact support or open a GitHub issue."); - tab.debugInfoEl = card.createEl("pre", { cls: "geode-debug-box", text: debugInfoText(tab) }); + tab.debugInfoEl = card.createEl("pre", { + cls: "geode-debug-box", + text: debugInfoText(tab), + }); debugSetting.addButton((button) => { button.setButtonText("Copy").onClick(async () => { @@ -354,9 +407,13 @@ function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): v } // renderSettingsTab draws every section into containerEl from the tab's current draft state. -export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { +export function renderSettingsTab( + tab: GeodeSettingTab, + containerEl: HTMLElement, +): void { renderHeader(containerEl); renderStorageSection(tab, containerEl); + renderSyncSection(tab, containerEl); renderSupportSection(tab, containerEl); } @@ -451,7 +508,9 @@ export class GeodeSettingTab extends PluginSettingTab { } async save(): Promise { - this.plugin.logger.info(`saving settings (provider=${this.draft.provider})`); + this.plugin.logger.info( + `saving settings (provider=${this.draft.provider})`, + ); this.plugin.settings = { ...this.draft }; await this.plugin.saveSettings(); this.refreshActionsUI(); @@ -468,9 +527,12 @@ export class GeodeSettingTab extends PluginSettingTab { this.connectionMessage = ""; this.refreshActionsUI(); - const secretAccessKey = this.app.secretStorage.getSecret(this.draft.secretId) ?? ""; + const secretAccessKey = + this.app.secretStorage.getSecret(this.draft.secretId) ?? ""; if (secretAccessKey === "") { - this.plugin.logger.warn(`no secret found for ID "${this.draft.secretId}"`); + this.plugin.logger.warn( + `no secret found for ID "${this.draft.secretId}"`, + ); } const result = await testConnection(this.draft, secretAccessKey); diff --git a/src/settings.test.ts b/src/settings.test.ts index 452609f..ca83567 100644 --- a/src/settings.test.ts +++ b/src/settings.test.ts @@ -82,6 +82,26 @@ const normalizeCases: { name: string; input: unknown; want: GeodeSettings }[] = input: { secretId: "foo" }, want: { ...DEFAULT_SETTINGS, secretId: "foo" }, }, + { + name: "ignorePatterns missing defaults to empty array", + input: {}, + want: DEFAULT_SETTINGS, + }, + { + name: "ignorePatterns non-array coerced to empty array", + input: { ignorePatterns: "not-an-array" }, + want: DEFAULT_SETTINGS, + }, + { + name: "ignorePatterns array with non-strings coerced to empty array", + input: { ignorePatterns: [1, 2, 3] }, + want: DEFAULT_SETTINGS, + }, + { + name: "ignorePatterns valid array passes through", + input: { ignorePatterns: ["private/**", "temp/*"] }, + want: { ...DEFAULT_SETTINGS, ignorePatterns: ["private/**", "temp/*"] }, + }, ]; for (const { name, input, want } of normalizeCases) { @@ -158,6 +178,18 @@ const settingsEqualCases: { name: string; a: GeodeSettings; b: GeodeSettings; wa b: { ...{ ...DEFAULT_SETTINGS, provider: "custom" }, provider: "r2" }, want: true, }, + { + name: "different ignorePatterns is not equal", + a: DEFAULT_SETTINGS, + b: { ...DEFAULT_SETTINGS, ignorePatterns: ["private/**"] }, + want: false, + }, + { + name: "same ignorePatterns is equal", + a: { ...DEFAULT_SETTINGS, ignorePatterns: ["a/**", "b/*"] }, + b: { ...DEFAULT_SETTINGS, ignorePatterns: ["a/**", "b/*"] }, + want: true, + }, ]; for (const { name, a, b, want } of settingsEqualCases) { diff --git a/src/settings.ts b/src/settings.ts index 7fff142..047dba1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,9 @@ export type GeodeSettings = { // it does not support forcing new entries onto a fixed ID, so we have to remember whichever // one they picked. secretId: string; + // ignorePatterns is a list of glob patterns for vault paths that should be excluded from sync. + // The built-in local_ prefix convention is always applied regardless of this list. + ignorePatterns: string[]; }; // DEFAULT_SETTINGS is the complete zero value used before any user configuration is loaded. @@ -24,6 +27,7 @@ export const DEFAULT_SETTINGS: GeodeSettings = { bucket: "", accessKeyId: "", secretId: "", + ignorePatterns: [], }; // stringOr returns v if it is a string, otherwise fallback. @@ -34,6 +38,14 @@ function stringOr(v: unknown, fallback: string): string { return fallback; } +// stringArrayOr returns v if it is a string array, otherwise fallback. +function stringArrayOr(v: unknown, fallback: string[]): string[] { + if (Array.isArray(v) && v.every((item) => typeof item === "string")) { + return v; + } + return fallback; +} + // providerOr returns "custom" if v is "custom", otherwise "r2". export function providerOr(v: unknown): "r2" | "custom" { if (v === "custom") { @@ -60,6 +72,10 @@ export function normalizeSettings(raw: unknown): GeodeSettings { bucket: stringOr(source.bucket, DEFAULT_SETTINGS.bucket), accessKeyId: stringOr(source.accessKeyId, DEFAULT_SETTINGS.accessKeyId), secretId: stringOr(source.secretId, DEFAULT_SETTINGS.secretId), + ignorePatterns: stringArrayOr( + source.ignorePatterns, + DEFAULT_SETTINGS.ignorePatterns, + ), }; } @@ -82,6 +98,19 @@ export function regionFor(settings: GeodeSettings): string { return settings.region; } +// arraysEqual reports whether two string arrays have the same length and elements in order. +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + // settingsEqual reports whether two settings values are identical field for field. Used to // derive whether a draft has unsaved changes by comparing it to the last saved settings, rather // than tracking a dirty flag that can't self-correct when an edit is reverted by hand. @@ -93,13 +122,18 @@ export function settingsEqual(a: GeodeSettings, b: GeodeSettings): boolean { a.region === b.region && a.bucket === b.bucket && a.accessKeyId === b.accessKeyId && - a.secretId === b.secretId + a.secretId === b.secretId && + arraysEqual(a.ignorePatterns, b.ignorePatterns) ); } // hasConnectionConfig reports whether settings have enough filled in to attempt a connection. export function hasConnectionConfig(settings: GeodeSettings): boolean { - if (settings.bucket === "" || settings.accessKeyId === "" || settings.secretId === "") { + if ( + settings.bucket === "" || + settings.accessKeyId === "" || + settings.secretId === "" + ) { return false; } if (settings.provider === "r2") { diff --git a/src/vault-adapter.ts b/src/vault-adapter.ts index 87c8678..a05d187 100644 --- a/src/vault-adapter.ts +++ b/src/vault-adapter.ts @@ -1,15 +1,31 @@ import type { DataAdapter, Vault } from "obsidian"; -import type { StateStore, VaultFile, VaultReader, VaultSnapshot } from "./vault-state.ts"; +import { shouldIgnore } from "./ignore.ts"; +import type { + StateStore, + VaultFile, + VaultReader, + VaultSnapshot, +} from "./vault-state.ts"; // createObsidianVaultReader returns a VaultReader backed by the real vault's file tree. Obsidian // already excludes .obsidian/** from Vault.getFiles(), so the plugin's own state file (which -// lives inside .obsidian/plugins/geode/) never shows up as a vault file to snapshot. -export function createObsidianVaultReader(vault: Vault): VaultReader { +// lives inside .obsidian/plugins/geode/) never shows up as a vault file to snapshot. Files +// matching ignorePatterns or the built-in local_ prefix convention are filtered out. +export function createObsidianVaultReader( + vault: Vault, + ignorePatterns: string[], +): VaultReader { return { listFiles: async () => { const files: VaultFile[] = []; for (const file of vault.getFiles()) { - files.push({ path: file.path, size: file.stat.size, mtime: file.stat.mtime }); + if (!shouldIgnore(file.path, ignorePatterns)) { + files.push({ + path: file.path, + size: file.stat.size, + mtime: file.stat.mtime, + }); + } } return files; }, @@ -27,7 +43,10 @@ export function createObsidianVaultReader(vault: Vault): VaultReader { // createObsidianStateStore returns a StateStore that persists the snapshot at statePath via the // vault adapter. A missing or unparseable file is treated as "no snapshot yet" rather than an // error, since the safest fallback for corrupt state is to start fresh, not to crash sync. -export function createObsidianStateStore(adapter: DataAdapter, statePath: string): StateStore { +export function createObsidianStateStore( + adapter: DataAdapter, + statePath: string, +): StateStore { const empty: VaultSnapshot = { files: [] }; return { From e5caed90326c565c5816d4400c2e3a8f6a8bfa54 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Fri, 17 Jul 2026 16:50:55 +0100 Subject: [PATCH 2/8] feat: add margin on top of heading --- src/settings-tab.ts | 3 ++- styles.css | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/settings-tab.ts b/src/settings-tab.ts index ac3fe63..8f854cd 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -256,7 +256,8 @@ function renderSyncSection( tab: GeodeSettingTab, containerEl: HTMLElement, ): void { - new Setting(containerEl).setName("Sync").setHeading(); + const heading = new Setting(containerEl).setName("Sync").setHeading(); + heading.settingEl.addClass("geode-sync-anchor"); const card = containerEl.createDiv({ cls: "geode-card" }); new Setting(card) diff --git a/styles.css b/styles.css index 8be1798..2209b08 100644 --- a/styles.css +++ b/styles.css @@ -49,6 +49,10 @@ border-bottom: none; } +.geode-sync-anchor { + margin-top: 1.5em; +} + .geode-support-anchor { margin-top: 1.5em; } From 52a37fabcabf0fe1642ef5e51948c116f311bcd5 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Fri, 17 Jul 2026 17:02:35 +0100 Subject: [PATCH 3/8] chore: biome linting and formatting --- src/main.ts | 52 ++++++++++---------------------------------- src/settings-tab.ts | 50 ++++++++++-------------------------------- src/settings.ts | 11 ++-------- src/vault-adapter.ts | 17 +++------------ 4 files changed, 27 insertions(+), 103 deletions(-) diff --git a/src/main.ts b/src/main.ts index 098a51e..595e51b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,16 +3,9 @@ import { Plugin } from "obsidian"; import { createLogger, type Logger, type LogSink } from "./log"; import { createLogSink } from "./log-adapter"; import { GeodeLogView, LOG_VIEW_TYPE } from "./log-view"; -import { - DEFAULT_SETTINGS, - type GeodeSettings, - normalizeSettings, -} from "./settings"; +import { DEFAULT_SETTINGS, type GeodeSettings, normalizeSettings } from "./settings"; import { GeodeSettingTab } from "./settings-tab"; -import { - createObsidianStateStore, - createObsidianVaultReader, -} from "./vault-adapter"; +import { createObsidianStateStore, createObsidianVaultReader } from "./vault-adapter"; import { diffSnapshots, takeSnapshot } from "./vault-state"; // VAULT_STATE_DEBOUNCE_MS delays a vault state refresh after the last file event, so a burst of @@ -49,17 +42,10 @@ export default class GeodePlugin extends Plugin { async onload() { await this.loadSettings(); - this.logSink = createLogSink( - this.app.vault.adapter, - this.manifest.dir, - MAX_LOG_LINES, - ); + this.logSink = createLogSink(this.app.vault.adapter, this.manifest.dir, MAX_LOG_LINES); this.logger = createLogger(this.logSink, LOG_MIN_LEVEL); - this.registerView( - LOG_VIEW_TYPE, - (leaf) => new GeodeLogView(leaf, this.logSink), - ); + this.registerView(LOG_VIEW_TYPE, (leaf) => new GeodeLogView(leaf, this.logSink)); this.addCommand({ id: "logs", name: "Logs", @@ -80,18 +66,10 @@ export default class GeodePlugin extends Plugin { this.app.workspace.onLayoutReady(() => { void this.refreshVaultState(); - this.registerEvent( - this.app.vault.on("create", () => this.scheduleVaultStateRefresh()), - ); - this.registerEvent( - this.app.vault.on("modify", () => this.scheduleVaultStateRefresh()), - ); - this.registerEvent( - this.app.vault.on("delete", () => this.scheduleVaultStateRefresh()), - ); - this.registerEvent( - this.app.vault.on("rename", () => this.scheduleVaultStateRefresh()), - ); + this.registerEvent(this.app.vault.on("create", () => this.scheduleVaultStateRefresh())); + this.registerEvent(this.app.vault.on("modify", () => this.scheduleVaultStateRefresh())); + this.registerEvent(this.app.vault.on("delete", () => this.scheduleVaultStateRefresh())); + this.registerEvent(this.app.vault.on("rename", () => this.scheduleVaultStateRefresh())); }); this.register(() => { @@ -157,22 +135,14 @@ export default class GeodePlugin extends Plugin { return; } - const store = createObsidianStateStore( - this.app.vault.adapter, - `${dir}/state.json`, - ); - const reader = createObsidianVaultReader( - this.app.vault, - this.settings.ignorePatterns, - ); + const store = createObsidianStateStore(this.app.vault.adapter, `${dir}/state.json`); + const reader = createObsidianVaultReader(this.app.vault, this.settings.ignorePatterns); const previous = await store.read(); const current = await takeSnapshot(reader, previous); const changes = diffSnapshots(previous, current); - this.logger.info( - `vault state refreshed (${changes.length} change(s) since last run)`, - ); + this.logger.info(`vault state refreshed (${changes.length} change(s) since last run)`); await store.write(current); } } diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 8f854cd..71f3c2b 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -38,9 +38,7 @@ function renderHeader(containerEl: HTMLElement): void { window.open("https://github.com/8thpark/geode", "_blank"); }); new ButtonComponent(links).setButtonText("Support").onClick(() => { - const target = containerEl.querySelector( - ".geode-support-anchor", - ); + const target = containerEl.querySelector(".geode-support-anchor"); if (target !== null) { target.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -63,10 +61,7 @@ function onFieldChanged(tab: GeodeSettingTab): void { } // renderProviderFields draws the fields specific to the selected provider. -function renderProviderFields( - tab: GeodeSettingTab, - containerEl: HTMLElement, -): void { +function renderProviderFields(tab: GeodeSettingTab, containerEl: HTMLElement): void { if (tab.draft.provider === "r2") { new Setting(containerEl) .setName("Account ID") @@ -117,9 +112,7 @@ function renderProviderFields( function renderSecretRow(tab: GeodeSettingTab, containerEl: HTMLElement): void { new Setting(containerEl) .setName("Secret access key") - .setDesc( - "Stored in Obsidian's built in secret manager, never in plugin data or synced files.", - ) + .setDesc("Stored in Obsidian's built in secret manager, never in plugin data or synced files.") .addComponent((el) => { const component = new SecretComponent(tab.app, el) .setValue(tab.draft.secretId) @@ -197,10 +190,7 @@ function renderActions(tab: GeodeSettingTab, containerEl: HTMLElement): void { } // renderStorageSection draws the card of storage related settings. -function renderStorageSection( - tab: GeodeSettingTab, - containerEl: HTMLElement, -): void { +function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const card = containerEl.createDiv({ cls: "geode-card" }); new Setting(card) @@ -252,10 +242,7 @@ function renderStorageSection( // renderSyncSection draws the sync exclusion settings: a textarea for user glob patterns and // a note about the built-in local_ prefix convention. -function renderSyncSection( - tab: GeodeSettingTab, - containerEl: HTMLElement, -): void { +function renderSyncSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const heading = new Setting(containerEl).setName("Sync").setHeading(); heading.settingEl.addClass("geode-sync-anchor"); const card = containerEl.createDiv({ cls: "geode-card" }); @@ -334,20 +321,13 @@ function debugInfoText(tab: GeodeSettingTab): string { } // flashButtonText sets a button's text to feedback, then reverts it to original after a delay. -function flashButtonText( - button: ButtonComponent, - original: string, - feedback: string, -): void { +function flashButtonText(button: ButtonComponent, original: string, feedback: string): void { button.setButtonText(feedback); window.setTimeout(() => button.setButtonText(original), 1500); } // renderSupportSection draws the Support heading and its card of docs, email, and debug info. -function renderSupportSection( - tab: GeodeSettingTab, - containerEl: HTMLElement, -): void { +function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const heading = new Setting(containerEl).setName("Support").setHeading(); heading.settingEl.addClass("geode-support-anchor"); const card = containerEl.createDiv({ cls: "geode-card" }); @@ -408,10 +388,7 @@ function renderSupportSection( } // renderSettingsTab draws every section into containerEl from the tab's current draft state. -export function renderSettingsTab( - tab: GeodeSettingTab, - containerEl: HTMLElement, -): void { +export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { renderHeader(containerEl); renderStorageSection(tab, containerEl); renderSyncSection(tab, containerEl); @@ -509,9 +486,7 @@ export class GeodeSettingTab extends PluginSettingTab { } async save(): Promise { - this.plugin.logger.info( - `saving settings (provider=${this.draft.provider})`, - ); + this.plugin.logger.info(`saving settings (provider=${this.draft.provider})`); this.plugin.settings = { ...this.draft }; await this.plugin.saveSettings(); this.refreshActionsUI(); @@ -528,12 +503,9 @@ export class GeodeSettingTab extends PluginSettingTab { this.connectionMessage = ""; this.refreshActionsUI(); - const secretAccessKey = - this.app.secretStorage.getSecret(this.draft.secretId) ?? ""; + const secretAccessKey = this.app.secretStorage.getSecret(this.draft.secretId) ?? ""; if (secretAccessKey === "") { - this.plugin.logger.warn( - `no secret found for ID "${this.draft.secretId}"`, - ); + this.plugin.logger.warn(`no secret found for ID "${this.draft.secretId}"`); } const result = await testConnection(this.draft, secretAccessKey); diff --git a/src/settings.ts b/src/settings.ts index 047dba1..14fdb45 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -72,10 +72,7 @@ export function normalizeSettings(raw: unknown): GeodeSettings { bucket: stringOr(source.bucket, DEFAULT_SETTINGS.bucket), accessKeyId: stringOr(source.accessKeyId, DEFAULT_SETTINGS.accessKeyId), secretId: stringOr(source.secretId, DEFAULT_SETTINGS.secretId), - ignorePatterns: stringArrayOr( - source.ignorePatterns, - DEFAULT_SETTINGS.ignorePatterns, - ), + ignorePatterns: stringArrayOr(source.ignorePatterns, DEFAULT_SETTINGS.ignorePatterns), }; } @@ -129,11 +126,7 @@ export function settingsEqual(a: GeodeSettings, b: GeodeSettings): boolean { // hasConnectionConfig reports whether settings have enough filled in to attempt a connection. export function hasConnectionConfig(settings: GeodeSettings): boolean { - if ( - settings.bucket === "" || - settings.accessKeyId === "" || - settings.secretId === "" - ) { + if (settings.bucket === "" || settings.accessKeyId === "" || settings.secretId === "") { return false; } if (settings.provider === "r2") { diff --git a/src/vault-adapter.ts b/src/vault-adapter.ts index a05d187..af8fe97 100644 --- a/src/vault-adapter.ts +++ b/src/vault-adapter.ts @@ -1,20 +1,12 @@ import type { DataAdapter, Vault } from "obsidian"; import { shouldIgnore } from "./ignore.ts"; -import type { - StateStore, - VaultFile, - VaultReader, - VaultSnapshot, -} from "./vault-state.ts"; +import type { StateStore, VaultFile, VaultReader, VaultSnapshot } from "./vault-state.ts"; // createObsidianVaultReader returns a VaultReader backed by the real vault's file tree. Obsidian // already excludes .obsidian/** from Vault.getFiles(), so the plugin's own state file (which // lives inside .obsidian/plugins/geode/) never shows up as a vault file to snapshot. Files // matching ignorePatterns or the built-in local_ prefix convention are filtered out. -export function createObsidianVaultReader( - vault: Vault, - ignorePatterns: string[], -): VaultReader { +export function createObsidianVaultReader(vault: Vault, ignorePatterns: string[]): VaultReader { return { listFiles: async () => { const files: VaultFile[] = []; @@ -43,10 +35,7 @@ export function createObsidianVaultReader( // createObsidianStateStore returns a StateStore that persists the snapshot at statePath via the // vault adapter. A missing or unparseable file is treated as "no snapshot yet" rather than an // error, since the safest fallback for corrupt state is to start fresh, not to crash sync. -export function createObsidianStateStore( - adapter: DataAdapter, - statePath: string, -): StateStore { +export function createObsidianStateStore(adapter: DataAdapter, statePath: string): StateStore { const empty: VaultSnapshot = { files: [] }; return { From 2308afa61371b139787cfb9d918bca4ff1b2ffd8 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Fri, 17 Jul 2026 17:22:11 +0100 Subject: [PATCH 4/8] refactor: pre compile the regex and use that in shouldIgnore call --- src/ignore.ts | 27 +++++++++++++++++++++------ src/settings-tab.ts | 1 + 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/ignore.ts b/src/ignore.ts index 14002b0..0123367 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -39,17 +39,32 @@ export function matchesGlob(path: string, pattern: string): boolean { return globToRegex(pattern).test(path); } -// shouldIgnore reports whether path should be excluded from sync, checking the built-in local_ -// prefix convention first, then any user-configured glob patterns. -export function shouldIgnore(path: string, patterns: string[]): boolean { +// compilePatterns converts a list of glob patterns to regular expressions once +// so the compiled forms can be reused across many shouldIgnoreCompiled calls. +export function compilePatterns(patterns: string[]): RegExp[] { + const compiled: RegExp[] = []; + for (const pattern of patterns) { + compiled.push(globToRegex(pattern)); + } + return compiled; +} + +// shouldIgnoreCompiled is like shouldIgnore but accepts pre-compiled patterns +// for callers that filter many paths against a fixed pattern set. +export function shouldIgnoreCompiled(path: string, compiled: RegExp[]): boolean { if (hasLocalPrefix(path)) { return true; } - for (const pattern of patterns) { - if (matchesGlob(path, pattern)) { + for (const re of compiled) { + if (re.test(path)) { return true; } } - return false; } + +// shouldIgnore reports whether path should be excluded from sync, checking the built-in local_ +// prefix convention first, then any user-configured glob patterns. +export function shouldIgnore(path: string, patterns: string[]): boolean { + return shouldIgnoreCompiled(path, compilePatterns(patterns)); +} diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 71f3c2b..a272e62 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -252,6 +252,7 @@ function renderSyncSection(tab: GeodeSettingTab, containerEl: HTMLElement): void .setDesc( "Glob patterns for files and folders to exclude from sync, one per line. " + "The local_ prefix is always excluded regardless of these patterns.", + +"for example: use private/** to exclude a folder and its contents", ) .addTextArea((text) => { text From 55ea66c6f44cefb66f5336e30974ec4100bd4fbf Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Fri, 17 Jul 2026 17:31:34 +0100 Subject: [PATCH 5/8] fix: fix desc string --- src/settings-tab.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/settings-tab.ts b/src/settings-tab.ts index a272e62..ea8be1c 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -251,8 +251,8 @@ function renderSyncSection(tab: GeodeSettingTab, containerEl: HTMLElement): void .setName("Ignore patterns") .setDesc( "Glob patterns for files and folders to exclude from sync, one per line. " + - "The local_ prefix is always excluded regardless of these patterns.", - +"for example: use private/** to exclude a folder and its contents", + "The local_ prefix is always excluded regardless of these patterns. " + + "For example: use private/** to exclude a folder and its contents.", ) .addTextArea((text) => { text From 3312dd17921a3fa390e197e42b9d5fd2e1ae46eb Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Mon, 20 Jul 2026 14:07:59 +0100 Subject: [PATCH 6/8] chore: resolve merge conflicts --- src/main.ts | 6 +++--- src/vault/obsidian.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index d525e12..77af089 100644 --- a/src/main.ts +++ b/src/main.ts @@ -204,7 +204,7 @@ export default class GeodePlugin extends Plugin { const secretAccessKey = this.app.secretStorage.getSecret(this.settings.secretId) ?? ""; const storage = createS3Client(this.settings, secretAccessKey); const stateStore = createObsidianStore(this.app.vault.adapter, `${dir}/state.json`); - const reader = createObsidianReader(this.app.vault); + const reader = createObsidianReader(this.app.vault, this.settings.ignorePatterns); const localWriter = createObsidianLocalWriter(this.app.vault.adapter); const previous = await stateStore.read(); @@ -255,8 +255,8 @@ export default class GeodePlugin extends Plugin { return; } - const store = createObsidianStateStore(this.app.vault.adapter, `${dir}/state.json`); - const reader = createObsidianVaultReader(this.app.vault, this.settings.ignorePatterns); + const store = createObsidianStore(this.app.vault.adapter, `${dir}/state.json`); + const reader = createObsidianReader(this.app.vault, this.settings.ignorePatterns); // Both callers fire this and forget (void), so a rejection here would surface as an // unhandled promise rejection. takeSnapshot can throw when a file vanishes mid-snapshot diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index bc69f4b..4dfb94e 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -1,6 +1,7 @@ import type { DataAdapter, Vault } from "obsidian"; import type { LocalWriter } from "../sync/execute.ts"; import { type FileInfo, isSnapshot, type Reader, type Snapshot, type Store } from "./vault.ts"; +import { shouldIgnore } from "../ignore.ts"; // ensureParentDir creates path's parent folder, and any folders above it, before a write that // might land somewhere the vault has never had a file before. mkdir is assumed to create @@ -20,12 +21,18 @@ async function ensureParentDir(adapter: DataAdapter, path: string): Promise { const files: FileInfo[] = []; for (const file of vault.getFiles()) { - files.push({ path: file.path, size: file.stat.size, mtime: file.stat.mtime }); + if (!shouldIgnore(file.path, ignorePatterns)) { + files.push({ + path: file.path, + size: file.stat.size, + mtime: file.stat.mtime, + }); + } } return files; }, From 32a8dd58bb8cb700afb18e6952f3d3cb68f48307 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Mon, 20 Jul 2026 14:13:46 +0100 Subject: [PATCH 7/8] fix: add ignore settings to createObsidianReader arguments in sync itest --- src/sync/sync.itest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync/sync.itest.ts b/src/sync/sync.itest.ts index 22b65da..334df69 100644 --- a/src/sync/sync.itest.ts +++ b/src/sync/sync.itest.ts @@ -51,7 +51,7 @@ function newDevice(): Device { const { vault, adapter } = nodeVault(root); return { root, - reader: createObsidianReader(vault), + reader: createObsidianReader(vault, liveSettings.ignorePatterns), writer: createObsidianLocalWriter(adapter), stateStore: createObsidianStore(adapter, STATE_PATH), }; From 2bd950fd2bdb46032f6cf2f4985eea1b50b57ec1 Mon Sep 17 00:00:00 2001 From: kwame-Owusu Date: Mon, 20 Jul 2026 14:16:42 +0100 Subject: [PATCH 8/8] chore: biome formatting --- src/vault/obsidian.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index 4dfb94e..b123774 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -1,7 +1,7 @@ import type { DataAdapter, Vault } from "obsidian"; +import { shouldIgnore } from "../ignore.ts"; import type { LocalWriter } from "../sync/execute.ts"; import { type FileInfo, isSnapshot, type Reader, type Snapshot, type Store } from "./vault.ts"; -import { shouldIgnore } from "../ignore.ts"; // ensureParentDir creates path's parent folder, and any folders above it, before a write that // might land somewhere the vault has never had a file before. mkdir is assumed to create