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);
});
}
68 changes: 68 additions & 0 deletions src/ignore.ts
Original file line number Diff line number Diff line change
@@ -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);
}
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));
}
7 changes: 5 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
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 @@ -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: {},
Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 27 additions & 1 deletion src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -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)
);
}

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