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..0123367 --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,70 @@ +// 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); +} + +// 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 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/main.ts b/src/main.ts index 2511029..fbccc0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,7 +35,11 @@ const VAULT_STATE_DEBOUNCE_MS = 2000; // 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; + }; }; // SyncStatus is the state the status bar item reflects. @@ -202,7 +206,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(); @@ -285,7 +289,7 @@ export default class GeodePlugin extends Plugin { } const store = createObsidianStore(this.app.vault.adapter, `${dir}/state.json`); - const reader = createObsidianReader(this.app.vault); + 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/settings/settings.test.ts b/src/settings/settings.test.ts index a7e496c..a1ab8ad 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/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/settings.ts b/src/settings/settings.ts index 5309083..59ef3d4 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -8,6 +8,7 @@ export const DEFAULT_SETTINGS: GeodeSettings = { bucket: "", accessKeyId: "", secretId: "", + ignorePatterns: [], }; // GeodeSettings is the persisted shape of a Geode plugin's user configuration. @@ -24,6 +25,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[]; }; // draftForDisplay returns the draft a settings tab should show for a given render. @@ -80,6 +84,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), }; } @@ -101,6 +106,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. @@ -112,7 +130,8 @@ 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) ); } @@ -123,3 +142,11 @@ 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; +} diff --git a/src/settings/tab.ts b/src/settings/tab.ts index e8d2bb2..5002ee6 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -28,6 +28,7 @@ type ConnectionStatus = "unknown" | "checking" | "ok" | "error"; export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { renderHeader(containerEl); renderStorageSection(tab, containerEl); + renderSyncSection(tab, containerEl); renderSupportSection(tab, containerEl); } @@ -302,6 +303,35 @@ function renderStorageSection(tab: GeodeSettingTab, containerEl: HTMLElement): v renderActions(tab, card); } +// a note about the built-in local_ prefix convention. +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" }); + + 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. " + + "For example: use private/** to exclude a folder and its contents.", + ) + .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%"; + }); +} + // renderSupportSection draws the Support heading and its card of docs, email, and debug info. function renderSupportSection(tab: GeodeSettingTab, containerEl: HTMLElement): void { const heading = new Setting(containerEl).setName("Support").setHeading(); @@ -345,7 +375,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 () => { diff --git a/src/sync/sync.itest.ts b/src/sync/sync.itest.ts index f66c25b..ec72895 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), }; diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index 569b61b..bfe74bf 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -1,4 +1,5 @@ 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"; @@ -32,7 +33,7 @@ export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter { // createObsidianReader returns a Reader 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 createObsidianReader(vault: Vault): Reader { +export function createObsidianReader(vault: Vault, ignorePatterns: string[]): Reader { return { fileExists: async (path) => { return vault.getFileByPath(path) !== null; @@ -40,7 +41,13 @@ export function createObsidianReader(vault: Vault): Reader { listFiles: async () => { 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; }, diff --git a/styles.css b/styles.css index 3c8177d..888e88e 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; }