From ca74eb21653ba98b46ae54ef88cc47cd89c98031 Mon Sep 17 00:00:00 2001 From: winters27 Date: Fri, 17 Apr 2026 18:30:21 -0700 Subject: [PATCH] feat(core): implement advanced path filtering allowlist and denylist logic --- package.json | 3 +++ src/settings.ts | 52 ++++++++-------------------------------- src/sync/pathFilter.ts | 21 ++++++++++++++++ src/sync/syncer.ts | 22 +++++++++++++---- tests/pathFilter.test.ts | 36 ++++++++++++++++++++++++++++ vitest.config.ts | 8 +++++++ 6 files changed, 96 insertions(+), 46 deletions(-) create mode 100644 src/sync/pathFilter.ts create mode 100644 tests/pathFilter.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 9c1f092..b6ccb6f 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/mocha": "^10.0.7", "@types/mustache": "^4.2.5", "@types/node": "^20.14.12", + "@types/picomatch": "^4.0.3", "@types/qrcode": "^1.5.5", "builtin-modules": "^4.0.0", "cross-env": "^7.0.3", @@ -52,6 +53,7 @@ "tslib": "^2.6.3", "tsx": "^4.16.2", "typescript": "^5.5.4", + "vitest": "^4.1.4", "webdav-server": "^2.6.2", "webpack": "^5.93.0", "webpack-cli": "^5.1.4", @@ -94,6 +96,7 @@ "p-queue": "^8.0.1", "path-browserify": "^1.0.1", "pcloud-sdk-js": "^2.0.0", + "picomatch": "^4.0.4", "process": "^0.11.10", "qrcode": "^1.5.3", "rfc4648": "^1.5.3", diff --git a/src/settings.ts b/src/settings.ts index 0e5f010..8637801 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import { SVG_DROPBOX, SVG_ONEDRIVE, SVG_PCLOUD, SVG_S3, SVG_WEBDAV, SVG_WEBDIS } from './icons'; +import { SVG_DROPBOX, SVG_ONEDRIVE, SVG_PCLOUD, SVG_S3, SVG_WEBDAV, SVG_WEBDIS } from './icons'; import { Eye, EyeOff, createElement } from "lucide"; import { type App, @@ -1785,6 +1785,7 @@ export class BYOCSettingTab extends PluginSettingTab { .setDesc(t("settings_autorun_desc")) .addDropdown((dropdown) => { dropdown.addOption("-1", t("settings_autorun_notset")); + dropdown.addOption(`${1000 * 30}`, "Every 30 seconds"); dropdown.addOption(`${1000 * 60 * 1}`, t("settings_autorun_1min")); dropdown.addOption(`${1000 * 60 * 5}`, t("settings_autorun_5min")); dropdown.addOption(`${1000 * 60 * 10}`, t("settings_autorun_10min")); @@ -1849,14 +1850,13 @@ export class BYOCSettingTab extends PluginSettingTab { .setDesc(t("settings_synconsave_desc")) .addDropdown((dropdown) => { dropdown.addOption("-1", t("settings_synconsave_disable")); - dropdown.addOption("1000", t("settings_synconsave_enable")); - // for backward compatibility, we need to use a number representing seconds - let syncOnSaveEnabled = false; - if ((this.plugin.settings.syncOnSaveAfterMilliseconds ?? -1) > 0) { - syncOnSaveEnabled = true; - } + dropdown.addOption("3000", "3 seconds"); + dropdown.addOption("5000", "5 seconds (Recommended)"); + dropdown.addOption("10000", "10 seconds"); + dropdown.addOption("30000", "30 seconds"); + dropdown - .setValue(`${syncOnSaveEnabled ? "1000" : "-1"}`) + .setValue(`${(this.plugin.settings.syncOnSaveAfterMilliseconds ?? -1) > 0 ? this.plugin.settings.syncOnSaveAfterMilliseconds : "-1"}`) .onChange(async (val: string) => { this.plugin.settings.syncOnSaveAfterMilliseconds = Number.parseInt(val); @@ -1940,6 +1940,7 @@ export class BYOCSettingTab extends PluginSettingTab { textArea.inputEl.cols = 30; textArea.inputEl.addClass("ignorepaths-textarea"); + textArea.setPlaceholder("*.pdf\n.obsidian/plugins/foo/**\nsecret-folder/"); }); new Setting(basicDiv) @@ -1961,6 +1962,7 @@ export class BYOCSettingTab extends PluginSettingTab { textArea.inputEl.cols = 30; textArea.inputEl.addClass("onlyallowpaths-textarea"); + textArea.setPlaceholder("notes/**\npublic/**/*.md"); }); ////////////////////////////////////////////////// @@ -2224,41 +2226,7 @@ export class BYOCSettingTab extends PluginSettingTab { }); }); - if (Platform.isMobile) { - new Setting(advDiv) - .setName(t("settings_enablemobilestatusbar")) - .setDesc(t("settings_enablemobilestatusbar_desc")) - .addDropdown(async (dropdown) => { - dropdown - .addOption("enable", t("enable")) - .addOption("disable", t("disable")); - dropdown - .setValue( - `${ - this.plugin.settings.enableMobileStatusBar - ? "enable" - : "disable" - }` - ) - .onChange(async (val) => { - if (val === "enable") { - this.plugin.settings.enableMobileStatusBar = true; - this.plugin.appContainerObserver = - changeMobileStatusBar("enable"); - } else { - this.plugin.settings.enableMobileStatusBar = false; - changeMobileStatusBar( - "disable", - this.plugin.appContainerObserver - ); - this.plugin.appContainerObserver?.disconnect(); - this.plugin.appContainerObserver = undefined; - } - await this.plugin.saveSettings(); - }); - }); - } ////////////////////////////////////////////////// // below for import and export functions diff --git a/src/sync/pathFilter.ts b/src/sync/pathFilter.ts new file mode 100644 index 0000000..0ee9ab0 --- /dev/null +++ b/src/sync/pathFilter.ts @@ -0,0 +1,21 @@ +import picomatch from "picomatch"; + +export function shouldSyncPath( + keyRaw: string, + ignorePaths: string[], + onlyAllowPaths: string[] +): boolean { + // If onlyAllowPaths is non-empty, the file must match at least one pattern + if (onlyAllowPaths.length > 0) { + const allowMatcher = picomatch(onlyAllowPaths, { dot: true }); + if (!allowMatcher(keyRaw)) return false; + } + + // If ignorePaths is non-empty, the file must NOT match any pattern + if (ignorePaths.length > 0) { + const ignoreMatcher = picomatch(ignorePaths, { dot: true }); + if (ignoreMatcher(keyRaw)) return false; + } + + return true; +} diff --git a/src/sync/syncer.ts b/src/sync/syncer.ts index a64141f..b1aa196 100644 --- a/src/sync/syncer.ts +++ b/src/sync/syncer.ts @@ -7,6 +7,7 @@ import type { BYOCPluginSettings, SyncTriggerSourceType, Entity, MixedEntity } f import { determineSyncDecision } from "./planner"; import { generateConflictFileName } from "./conflict"; import { copyFileOrFolder } from "../copyLogic"; +import { shouldSyncPath } from "./pathFilter"; // ─── Folder-aware sorter ─────────────────────────────────────────────────────── // Correct execution order to prevent parent-before-child violations: @@ -134,6 +135,17 @@ export async function syncer( } } + // Phase 1.5: Path Filtering + const ignorePaths = settings.ignorePaths ?? []; + const onlyAllowPaths = settings.onlyAllowPaths ?? []; + if (ignorePaths.length > 0 || onlyAllowPaths.length > 0) { + for (const [key] of nodes) { + if (!shouldSyncPath(key, ignorePaths, onlyAllowPaths)) { + nodes.delete(key); + } + } + } + // Phase 2: Planner const unsortedActions = Array.from(nodes.values()).map(node => { node.decision = determineSyncDecision(node, settings.conflictAction || "smart_conflict"); @@ -143,16 +155,18 @@ export async function syncer( // M1: Enforce folder-before-file creation order, file-before-folder delete order. const syncActions = sortSyncActions(unsortedActions); - // M2: Skip protection check on empty vault (avoids division by zero/NaN). - if (localWalk.length > 0) { + // M2: Skip protection check if no valid local files are being tracked + const validLocalCount = Array.from(nodes.values()).filter(n => n.local !== undefined).length; + + if (validLocalCount > 0) { let deleteModifyCount = 0; for (const action of syncActions) { - if (action.decision?.includes("delete") || action.decision?.includes("pull")) { + if (action.decision?.includes("delete")) { deleteModifyCount++; } } - const protectErr = getProtectError(settings.protectModifyPercentage || 50, deleteModifyCount, localWalk.length); + const protectErr = getProtectError(settings.protectModifyPercentage || 50, deleteModifyCount, validLocalCount); if (protectErr !== "") { throw Error(`Protection Triggered: ${protectErr}`); } diff --git a/tests/pathFilter.test.ts b/tests/pathFilter.test.ts new file mode 100644 index 0000000..fb47337 --- /dev/null +++ b/tests/pathFilter.test.ts @@ -0,0 +1,36 @@ +import { describe, it, assert } from "vitest"; +import { shouldSyncPath } from "../src/sync/pathFilter"; + +describe("shouldSyncPath", () => { + it("allows everything when both lists are empty", () => { + assert.isTrue(shouldSyncPath("notes/foo.md", [], [])); + }); + + it("blocks a file matching ignorePaths", () => { + assert.isFalse(shouldSyncPath("big.pdf", ["*.pdf"], [])); + }); + + it("allows a file not matching ignorePaths", () => { + assert.isTrue(shouldSyncPath("notes/foo.md", ["*.pdf"], [])); + }); + + it("allows a file matching onlyAllowPaths", () => { + assert.isTrue(shouldSyncPath("notes/foo.md", [], ["notes/**"])); + }); + + it("blocks a file not matching onlyAllowPaths", () => { + assert.isFalse(shouldSyncPath("images/bar.png", [], ["notes/**"])); + }); + + it("ignorePaths takes precedence over onlyAllowPaths", () => { + assert.isFalse(shouldSyncPath("notes/secret.md", ["**/secret.*"], ["notes/**"])); + }); + + it("supports dot-files in globs", () => { + assert.isTrue(shouldSyncPath(".obsidian/snippets/custom.css", [], [".obsidian/snippets/**"])); + }); + + it("blocks dot-files when ignored", () => { + assert.isFalse(shouldSyncPath(".obsidian/plugins/foo/data.json", [".obsidian/plugins/*/data.json"], [])); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9aaa705 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + include: ['tests/**/*.test.ts'], + }, +});