mirror of
https://github.com/winters27/obsidian-byoc.git
synced 2026-07-22 06:53:46 +00:00
feat(core): implement advanced path filtering allowlist and denylist logic
This commit is contained in:
parent
9e69f0793f
commit
ca74eb2165
6 changed files with 96 additions and 46 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
src/sync/pathFilter.ts
Normal file
21
src/sync/pathFilter.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
36
tests/pathFilter.test.ts
Normal file
36
tests/pathFilter.test.ts
Normal file
|
|
@ -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"], []));
|
||||
});
|
||||
});
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue