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..bdcb3bc --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,68 @@ +// 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 regex: * and ? match within one path +// segment, ** matches across segments, and every other character is literal. A leading / is +// stripped from the pattern. +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 b79f799..8de4b48 100644 --- a/src/main.ts +++ b/src/main.ts @@ -337,7 +337,10 @@ export default class GeodePlugin extends Plugin { private onboardingActions(): Actions { return { localPaths: async () => { - const files = await createObsidianReader(this.app.vault).listFiles(); + const files = await createObsidianReader( + this.app.vault, + this.settings.ignorePatterns, + ).listFiles(); const paths: string[] = []; for (const file of files) { paths.push(file.path); @@ -570,7 +573,7 @@ export default class GeodePlugin extends Plugin { `${dir}/state.json`, this.settings, ); - const reader = createObsidianReader(this.app.vault); + const reader = createObsidianReader(this.app.vault, this.settings.ignorePatterns); const localWriter = createObsidianLocalWriter(this.app.vault.adapter); // Flush every open editor to disk right before the snapshot below reads the vault, so a file diff --git a/src/settings/settings.test.ts b/src/settings/settings.test.ts index ffa6ac9..4e9f339 100644 --- a/src/settings/settings.test.ts +++ b/src/settings/settings.test.ts @@ -242,6 +242,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/*"] }, + }, { name: "prefix missing defaults to the bucket root", input: {}, @@ -593,6 +613,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 167a494..7021c41 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -9,6 +9,7 @@ export const DEFAULT_SETTINGS: GeodeSettings = { prefix: "", accessKeyId: "", secretId: "", + ignorePatterns: [], }; // ConnectionStatus is the current in-memory state of a Test Connection check. @@ -33,6 +34,9 @@ export type GeodeSettings = { // secretId is a SecretStorage reference name, not the secret value itself; Obsidian's picker // won't force a new entry onto a fixed ID, so we remember whichever name was 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[]; }; // SaveTarget is the narrow persistence surface needed to save a settings draft. @@ -193,6 +197,7 @@ export function normalizeSettings(raw: unknown): GeodeSettings { prefix: stringOr(source.prefix, DEFAULT_SETTINGS.prefix), accessKeyId: stringOr(source.accessKeyId, DEFAULT_SETTINGS.accessKeyId), secretId: stringOr(source.secretId, DEFAULT_SETTINGS.secretId), + ignorePatterns: stringArrayOr(source.ignorePatterns, DEFAULT_SETTINGS.ignorePatterns), }; } @@ -251,6 +256,18 @@ export function regionFor(settings: GeodeSettings): string { return settings.region.trim(); } +// 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; +} // saveDraft persists a copy of draft when the current connection state allows it. export async function saveDraft( target: SaveTarget, @@ -277,7 +294,8 @@ export function settingsEqual(a: GeodeSettings, b: GeodeSettings): boolean { a.bucket === b.bucket && a.prefix === b.prefix && a.accessKeyId === b.accessKeyId && - a.secretId === b.secretId + a.secretId === b.secretId && + arraysEqual(a.ignorePatterns, b.ignorePatterns) ); } @@ -304,3 +322,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 8dd9130..5077acd 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -32,6 +32,7 @@ const DEBUG_LABEL_WIDTH = 12; export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void { renderHeader(containerEl); renderStorageSection(tab, containerEl); + renderSyncSection(tab, containerEl); renderSupportSection(tab, containerEl); } @@ -367,6 +368,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(); @@ -410,7 +440,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 2f404f9..2a634ad 100644 --- a/src/sync/sync.itest.ts +++ b/src/sync/sync.itest.ts @@ -57,7 +57,7 @@ function newDevice(settings: GeodeSettings = liveSettings): Device { const { vault, adapter } = nodeVault(root); return { root, - reader: createObsidianReader(vault), + reader: createObsidianReader(vault, liveSettings.ignorePatterns), writer: createObsidianLocalWriter(adapter), stateStore: createObsidianStore(adapter, STATE_PATH, settings), }; diff --git a/src/vault/obsidian.ts b/src/vault/obsidian.ts index 91d6ce3..4a03ec8 100644 --- a/src/vault/obsidian.ts +++ b/src/vault/obsidian.ts @@ -1,4 +1,5 @@ import type { DataAdapter, Vault, Workspace } from "obsidian"; +import { shouldIgnore } from "../ignore.ts"; import type { GeodeSettings } from "../settings/settings.ts"; import { DRIFT_MESSAGE, type LocalWriter, type WriteMode } from "../sync/execute.ts"; import { @@ -59,19 +60,22 @@ export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter { }; } -// createObsidianReader returns a Reader backed by the real vault's file tree, relying on Obsidian -// already excluding .obsidian/** from Vault.getFiles() so the plugin's own state file never shows -// up as a vault file to snapshot. -export function createObsidianReader(vault: Vault): Reader { +// 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, ignorePatterns: string[]): Reader { return { listFiles: async () => { const files: FileInfo[] = []; for (const file of vault.getFiles()) { - files.push({ - path: normalizePath(file.path), - size: file.stat.size, - mtime: file.stat.mtime, - }); + const normalized = normalizePath(file.path); + if (!shouldIgnore(normalized, ignorePatterns)) { + files.push({ + path: normalized, + size: file.stat.size, + mtime: file.stat.mtime, + }); + } } return files; }, diff --git a/styles.css b/styles.css index e171221..b4dce1e 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; }