feat(settings): add triggerPrefix with 6-option allowlist

This commit is contained in:
Etai Solomon 2026-04-26 10:33:08 +03:00
parent db4eadc491
commit 94463a0de0
5 changed files with 59 additions and 13 deletions

View file

@ -61,7 +61,11 @@ export default class TagFinderPlugin extends Plugin {
this.uninstallHook?.();
this.uninstallHook = null;
if (this.settings.enableQuickSwitcherHook) {
this.uninstallHook = installQuickSwitcherHook(this.app, (q) => this.openTagPicker(q));
this.uninstallHook = installQuickSwitcherHook(
this.app,
(q) => this.openTagPicker(q),
this.settings.triggerPrefix,
);
}
}

View file

@ -1,24 +1,28 @@
import { App } from "obsidian";
export type QuickSwitcherReroute = (initialQueryAfterHash: string) => void;
export type QuickSwitcherReroute = (initialQueryAfterPrefix: string) => void;
/**
* Install a document-level 'input' capture listener that detects when the
* user has typed '#' as the first character in the built-in Quick Switcher
* prompt. When that happens we close the built-in modal and invoke the
* reroute callback with the text after the '#'.
* user has typed `triggerPrefix` as the first character in the built-in
* Quick Switcher prompt. When that happens we close the built-in modal and
* invoke the reroute callback with the text after the prefix.
*
* Returns an uninstaller. DOM-inspection errors (e.g. Obsidian changes the
* switcher internals) permanently disable the hook for the session; errors
* thrown by the `reroute` callback are logged but do NOT disable the hook.
*/
export function installQuickSwitcherHook(app: App, reroute: QuickSwitcherReroute): () => void {
export function installQuickSwitcherHook(
app: App,
reroute: QuickSwitcherReroute,
triggerPrefix: string,
): () => void {
let disabled = false;
const handler = (evt: Event) => {
if (disabled) return;
let queryAfterHash: string;
let queryAfterPrefix: string;
try {
const target = evt.target as HTMLElement | null;
if (!(target instanceof HTMLInputElement)) return;
@ -29,7 +33,7 @@ export function installQuickSwitcherHook(app: App, reroute: QuickSwitcherReroute
if (!target.closest(".prompt .prompt-input-container")) return;
const value = target.value;
if (!value.startsWith("#")) return;
if (!value.startsWith(triggerPrefix)) return;
// Stop the event from reaching the Quick Switcher's own input handler.
evt.stopPropagation();
@ -47,7 +51,7 @@ export function installQuickSwitcherHook(app: App, reroute: QuickSwitcherReroute
cancelable: true,
}));
queryAfterHash = value.slice(1);
queryAfterPrefix = value.slice(triggerPrefix.length);
} catch (err) {
disabled = true;
console.warn("[tag-finder] quick-switcher hook disabled after error", err);
@ -56,9 +60,9 @@ export function installQuickSwitcherHook(app: App, reroute: QuickSwitcherReroute
// Call reroute outside the DOM-inspection try/catch so that downstream
// errors don't disable the hook. They'll surface in the console but the
// next '#' keypress will still be intercepted.
// next prefix keypress will still be intercepted.
try {
reroute(queryAfterHash);
reroute(queryAfterPrefix);
} catch (err) {
console.warn("[tag-finder] reroute callback threw", err);
}

View file

@ -1,4 +1,10 @@
import { DEFAULT_SETTINGS, EmptyStateMode, PluginSettings } from "./types";
import {
DEFAULT_SETTINGS,
EmptyStateMode,
PluginSettings,
TriggerPrefix,
VALID_PREFIXES,
} from "./types";
const VALID_MODES: EmptyStateMode[] = ["recent-then-usage", "usage", "alphabetical", "blank"];
@ -6,6 +12,10 @@ function isValidMode(v: unknown): v is EmptyStateMode {
return typeof v === "string" && (VALID_MODES as string[]).includes(v);
}
function isValidPrefix(v: unknown): v is TriggerPrefix {
return typeof v === "string" && (VALID_PREFIXES as string[]).includes(v);
}
export function normalizeSettings(raw: unknown): PluginSettings {
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
@ -22,5 +32,9 @@ export function normalizeSettings(raw: unknown): PluginSettings {
recentLimit = Math.max(0, Math.min(30, Math.floor(source.recentLimit)));
}
return { emptyStateMode, enableQuickSwitcherHook, recentLimit };
const triggerPrefix = isValidPrefix(source.triggerPrefix)
? source.triggerPrefix
: DEFAULT_SETTINGS.triggerPrefix;
return { emptyStateMode, enableQuickSwitcherHook, recentLimit, triggerPrefix };
}

View file

@ -11,17 +11,24 @@ export type EmptyStateMode =
| "alphabetical"
| "blank";
export type TriggerPrefix = "#" | ":" | "@" | "!" | ">" | "?";
export const VALID_PREFIXES: TriggerPrefix[] = ["#", ":", "@", "!", ">", "?"];
export type PluginSettings = {
emptyStateMode: EmptyStateMode;
enableQuickSwitcherHook: boolean;
/** 0..30; 0 disables recent-tags entirely. */
recentLimit: number;
/** Single-character prefix that activates the Quick Switcher hook. */
triggerPrefix: TriggerPrefix;
};
export const DEFAULT_SETTINGS: PluginSettings = {
emptyStateMode: "recent-then-usage",
enableQuickSwitcherHook: true,
recentLimit: 10,
triggerPrefix: "#",
};
/** Shape of the blob stored via plugin.saveData(). */

View file

@ -47,4 +47,21 @@ describe("normalizeSettings", () => {
it("treats array input as empty object (all defaults)", () => {
expect(normalizeSettings(["not", "an", "object"])).toEqual(DEFAULT_SETTINGS);
});
it("keeps each valid triggerPrefix", () => {
for (const prefix of ["#", ":", "@", "!", ">", "?"] as const) {
expect(normalizeSettings({ triggerPrefix: prefix }).triggerPrefix).toBe(prefix);
}
});
it("falls back to '#' when triggerPrefix is missing", () => {
expect(normalizeSettings({}).triggerPrefix).toBe("#");
});
it("falls back to '#' when triggerPrefix is invalid", () => {
const cases = ["", "##", "a", "Z", "1", " ", "/", null, 42, ["#"], { v: "#" }];
for (const bad of cases) {
expect(normalizeSettings({ triggerPrefix: bad }).triggerPrefix).toBe("#");
}
});
});