Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions src/ignore.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
70 changes: 70 additions & 0 deletions src/ignore.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
kwame-Owusu marked this conversation as resolved.

// 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));
}
10 changes: 7 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/settings/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 28 additions & 1 deletion src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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),
};
}

Expand All @@ -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.
Expand All @@ -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)
);
}

Expand All @@ -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;
}
Loading