Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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));
}
8 changes: 6 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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.
Expand Down Expand Up @@ -132,7 +136,7 @@ export default class GeodePlugin extends Plugin {
}

const store = createObsidianStateStore(this.app.vault.adapter, `${dir}/state.json`);
const reader = createObsidianVaultReader(this.app.vault);
const reader = createObsidianVaultReader(this.app.vault, this.settings.ignorePatterns);

const previous = await store.read();
const current = await takeSnapshot(reader, previous);
Expand Down
40 changes: 38 additions & 2 deletions src/settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@
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: " · ",
Expand Down Expand Up @@ -238,6 +240,36 @@
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 {
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",

Check failure on line 255 in src/settings-tab.ts

View workflow job for this annotation

GitHub Actions / Build

Expected 1 arguments, but got 2.
)
.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) {
Expand Down Expand Up @@ -338,7 +370,10 @@
.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 () => {
Expand All @@ -357,6 +392,7 @@
export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void {
renderHeader(containerEl);
renderStorageSection(tab, containerEl);
renderSyncSection(tab, containerEl);
renderSupportSection(tab, containerEl);
}

Expand Down
32 changes: 32 additions & 0 deletions src/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
Loading
Loading