Enhance inclusion/exclusion patterns settings (#1261)

This commit is contained in:
Zero Liu 2025-02-18 23:01:20 -08:00 committed by GitHub
parent 17f773ae62
commit 2c99284da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1288 additions and 364 deletions

View file

@ -1,7 +1,7 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
roots: ["<rootDir>/src", "<rootDir>/tests"],
roots: ["<rootDir>/src"],
transform: {
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest",
},
@ -10,7 +10,7 @@ module.exports = {
"^@/(.*)$": "<rootDir>/src/$1",
"^obsidian$": "<rootDir>/__mocks__/obsidian.js",
},
testRegex: "(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
testRegex: ".*\\.test\\.(jsx?|tsx?)$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
testPathIgnorePatterns: ["/node_modules/"],
setupFiles: ["<rootDir>/jest.setup.js"],

View file

@ -1,22 +1,38 @@
import { App, TFile } from "obsidian";
import { App, FuzzyMatch, TFile } from "obsidian";
import { BaseNoteModal } from "./BaseNoteModal";
interface AddContextNoteModalProps {
app: App;
onNoteSelect: (note: TFile) => void;
excludeNotePaths: string[];
titleOnly?: boolean;
}
export class AddContextNoteModal extends BaseNoteModal<TFile> {
private onNoteSelect: (note: TFile) => void;
private titleOnly: boolean;
constructor({ app, onNoteSelect, excludeNotePaths }: AddContextNoteModalProps) {
constructor({
app,
onNoteSelect,
excludeNotePaths,
titleOnly = false,
}: AddContextNoteModalProps) {
super(app);
this.onNoteSelect = onNoteSelect;
this.availableNotes = this.getOrderedNotes(excludeNotePaths);
this.titleOnly = titleOnly;
}
getItems(): TFile[] {
if (this.titleOnly) {
// Deduplicate notes by basename
const uniqueNotes = new Map<string, TFile>();
this.availableNotes.forEach((note) => {
uniqueNotes.set(note.basename, note);
});
return Array.from(uniqueNotes.values());
}
return this.availableNotes;
}
@ -28,4 +44,20 @@ export class AddContextNoteModal extends BaseNoteModal<TFile> {
onChooseItem(note: TFile, evt: MouseEvent | KeyboardEvent) {
this.onNoteSelect(note);
}
renderSuggestion(match: FuzzyMatch<TFile>, el: HTMLElement) {
const suggestionEl = el.createDiv({ cls: "pointer-events-none" });
if (match.item instanceof TFile) {
const titleEl = suggestionEl.createDiv();
const file = match.item;
titleEl.setText(
this.formatNoteTitle(file.basename, file === this.activeNote, file.extension)
);
if (!this.titleOnly) {
const pathEl = suggestionEl.createDiv({ cls: "mt-1 text-muted text-xs" });
pathEl.setText(file.path);
}
}
}
}

View file

@ -146,7 +146,7 @@ function AddPromptModalContent({
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
<Button variant="secondary" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid || isSubmitting}>

View file

@ -1,4 +1,4 @@
import { App, FuzzySuggestModal, TFile, FuzzyMatch } from "obsidian";
import { App, FuzzySuggestModal, TFile } from "obsidian";
export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
protected activeNote: TFile | null;
@ -48,18 +48,4 @@ export abstract class BaseNoteModal<T> extends FuzzySuggestModal<T> {
}
return title;
}
renderSuggestion(match: FuzzyMatch<T>, el: HTMLElement) {
const suggestionEl = el.createDiv({ cls: "suggestion-item pointer-events-none" });
const titleEl = suggestionEl.createDiv({ cls: "suggestion-title" });
const pathEl = suggestionEl.createDiv({ cls: "suggestion-path mt-1 text-muted text-xs" });
if (match.item instanceof TFile) {
const file = match.item;
titleEl.setText(
this.formatNoteTitle(file.basename, file === this.activeNote, file.extension)
);
pathEl.setText(file.path);
}
}
}

View file

@ -0,0 +1,83 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
function CustomPatternInputModalContent({
onConfirm,
onCancel,
}: {
onConfirm: (pattern: string) => void;
onCancel: () => void;
}) {
// TODO: Add validation
const [pattern, setPattern] = useState("");
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
onConfirm(pattern);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
Comma separated list of paths, tags, note titles or file extension e.g. folder1,
folder1/folder2, #tag1, #tag2, [[note1]], [[note2]], *.jpg, *.excallidraw.md
</div>
<Input
placeholder="Enter the pattern"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button variant="default" onClick={() => onConfirm(pattern)}>
Confirm
</Button>
</div>
</div>
);
}
export class CustomPatternInputModal extends Modal {
private root: Root;
constructor(
app: App,
private onConfirm: (pattern: string) => void
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle("Add Custom Pattern");
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<CustomPatternInputModalContent onConfirm={handleConfirm} onCancel={handleCancel} />
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,92 @@
import { App, Modal } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
function ExtensionInputModalContent({
onConfirm,
onCancel,
}: {
onConfirm: (extension: string) => void;
onCancel: () => void;
}) {
const [extension, setExtension] = useState("");
const [error, setError] = useState<string | null>(null);
const validateAndConfirm = (value: string) => {
if (value.includes(" ")) {
setError("Extension cannot contain spaces");
return;
}
setError(null);
onConfirm(value);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
validateAndConfirm(extension);
}
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Input
placeholder="Enter the extension (e.g. txt, excalidraw)"
value={extension}
onChange={(e) => {
setExtension(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
/>
{error && <p className="text-error text-sm">{error}</p>}
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button variant="default" onClick={() => validateAndConfirm(extension)}>
Confirm
</Button>
</div>
</div>
);
}
export class ExtensionInputModal extends Modal {
private root: Root;
constructor(
app: App,
private onConfirm: (extension: string) => void
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle("Add Extension");
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleConfirm = (extension: string) => {
this.onConfirm(extension);
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<ExtensionInputModalContent onConfirm={handleConfirm} onCancel={handleCancel} />
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,39 @@
import { App, FuzzySuggestModal } from "obsidian";
import { extractAppIgnoreSettings } from "@/search/searchUtils";
export class FolderSearchModal extends FuzzySuggestModal<string> {
constructor(
app: App,
private onChooseFolder: (folder: string) => void
) {
super(app);
}
getItems(): string[] {
const folderSet = new Set<string>();
const ignoredFolders = extractAppIgnoreSettings(this.app);
// Get all files in vault
this.app.vault.getAllLoadedFiles().forEach((file) => {
if (file.parent?.path && file.parent.path !== "/") {
// Check if the folder or any of its parent folders are ignored
const shouldInclude = !ignoredFolders.some(
(ignored) => file.parent!.path === ignored || file.parent!.path.startsWith(ignored + "/")
);
if (shouldInclude) {
folderSet.add(file.parent.path);
}
}
});
return Array.from(folderSet);
}
getItemText(tag: string): string {
return tag;
}
onChooseItem(folder: string, evt: MouseEvent | KeyboardEvent) {
this.onChooseFolder(folder);
}
}

View file

@ -0,0 +1,289 @@
import { App, Modal } from "obsidian";
import { Button } from "@/components/ui/button";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import {
categorizePatterns,
getDecodedPatterns,
createPatternSettingsValue,
} from "@/search/searchUtils";
import { File, FileText, Folder, Tag, Wrench, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TagSearchModal } from "@/components/modals/TagSearchModal";
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
import { FolderSearchModal } from "@/components/modals/FolderSearchModal";
import { ExtensionInputModal } from "@/components/modals/ExtensionInputModal";
import { CustomPatternInputModal } from "@/components/modals/CustomPatternInputModal";
function PatternListGroup({
title,
patterns,
onRemove,
}: {
title: string;
patterns: string[];
onRemove: (pattern: string) => void;
}) {
return (
<div className="grid grid-cols-4 gap-2">
<div className="font-bold">{title}</div>
<ul className="list-disc list-inside pl-0 m-0 col-span-3 flex flex-col gap-1">
{patterns.map((pattern) => (
<li key={pattern} className="flex gap-2 hover:bg-dropdown-hover pl-2 pr-1 rounded-md">
<span className="flex-1 truncate whitespace-nowrap">{pattern}</span>
<Button variant="ghost2" size="fit" onClick={() => onRemove(pattern)}>
<X className="size-4" />
</Button>
</li>
))}
</ul>
</div>
);
}
function PatternMatchingModalContent({
value: initialValue,
onUpdate,
container,
}: {
value: string;
onUpdate: (value: string) => void;
container: HTMLElement;
}) {
const [value, setValue] = useState(initialValue);
const patterns = getDecodedPatterns(value);
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
const updateCategories = (newCategories: {
tagPatterns?: string[];
extensionPatterns?: string[];
folderPatterns?: string[];
notePatterns?: string[];
}) => {
const newValue = createPatternSettingsValue({
tagPatterns: newCategories.tagPatterns ?? tagPatterns,
extensionPatterns: newCategories.extensionPatterns ?? extensionPatterns,
folderPatterns: newCategories.folderPatterns ?? folderPatterns,
notePatterns: newCategories.notePatterns ?? notePatterns,
});
setValue(newValue);
onUpdate(newValue);
};
const hasValue =
tagPatterns.length > 0 ||
extensionPatterns.length > 0 ||
folderPatterns.length > 0 ||
notePatterns.length > 0;
return (
<div className="flex flex-col gap-4 mt-2">
<div className="flex flex-col gap-2 p-4 border border-border border-solid rounded-md max-h-[400px] overflow-y-auto">
{!hasValue && (
<div className="text-center text-sm text-muted-foreground">No patterns specified</div>
)}
{tagPatterns.length > 0 && (
<PatternListGroup
title="Tags"
patterns={tagPatterns}
onRemove={(pattern) => {
const newPatterns = tagPatterns.filter((p) => p !== pattern);
updateCategories({
tagPatterns: newPatterns,
});
}}
/>
)}
{extensionPatterns.length > 0 && (
<PatternListGroup
title="Extensions"
patterns={extensionPatterns}
onRemove={(pattern) => {
const newPatterns = extensionPatterns.filter((p) => p !== pattern);
updateCategories({
extensionPatterns: newPatterns,
});
}}
/>
)}
{folderPatterns.length > 0 && (
<PatternListGroup
title="Folders"
patterns={folderPatterns}
onRemove={(pattern) => {
const newPatterns = folderPatterns.filter((p) => p !== pattern);
updateCategories({
folderPatterns: newPatterns,
});
}}
/>
)}
{notePatterns.length > 0 && (
<PatternListGroup
title="Notes"
patterns={notePatterns}
onRemove={(pattern) => {
const newPatterns = notePatterns.filter((p) => p !== pattern);
updateCategories({
notePatterns: newPatterns,
});
}}
/>
)}
</div>
<div className="flex justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Add...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" container={container}>
<DropdownMenuItem
onSelect={() => {
new TagSearchModal(app, (tag) => {
const tagPattern = `#${tag}`;
if (tagPatterns.includes(tagPattern)) {
return;
}
updateCategories({
tagPatterns: [...tagPatterns, tagPattern],
});
}).open();
}}
>
<div className="flex items-center gap-2">
<Tag className="size-4" />
Tag
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new FolderSearchModal(app, (folder) => {
if (folderPatterns.includes(folder)) {
return;
}
updateCategories({
folderPatterns: [...folderPatterns, folder],
});
}).open();
}}
>
<div className="flex items-center gap-2">
<Folder className="size-4" />
Folder
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new AddContextNoteModal({
app,
onNoteSelect: (note) => {
const notePattern = `[[${note.basename}]]`;
if (notePatterns.includes(notePattern)) {
return;
}
updateCategories({
notePatterns: [...notePatterns, notePattern],
});
},
excludeNotePaths: [],
titleOnly: true,
}).open();
}}
>
<div className="flex items-center gap-2">
<FileText className="size-4" />
Note
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ExtensionInputModal(app, (extension) => {
const extensionPattern = `*.${extension}`;
if (extensionPatterns.includes(extensionPattern)) {
return;
}
updateCategories({
extensionPatterns: [...extensionPatterns, extensionPattern],
});
}).open();
}}
>
<div className="flex items-center gap-2">
<File className="size-4" />
Extension
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new CustomPatternInputModal(app, (value) => {
const patterns = getDecodedPatterns(value);
const {
tagPatterns: newTagPatterns,
extensionPatterns: newExtensionPatterns,
folderPatterns: newFolderPatterns,
notePatterns: newNotePatterns,
} = categorizePatterns(patterns);
updateCategories({
tagPatterns: [...tagPatterns, ...newTagPatterns],
extensionPatterns: [...extensionPatterns, ...newExtensionPatterns],
folderPatterns: [...folderPatterns, ...newFolderPatterns],
notePatterns: [...notePatterns, ...newNotePatterns],
});
}).open();
}}
>
<div className="flex items-center gap-2">
<Wrench className="size-4" />
Custom
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export class PatternMatchingModal extends Modal {
private root: Root;
constructor(
app: App,
private onUpdate: (value: string) => void,
/** The raw pattern matching value, separated by commas */
private value: string,
title: string
) {
super(app);
// https://docs.obsidian.md/Reference/TypeScript+API/Modal/setTitle
// @ts-ignore
this.setTitle(title);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleUpdate = (value: string) => {
this.onUpdate(value);
};
this.root.render(
<PatternMatchingModalContent
value={this.value}
onUpdate={handleUpdate}
container={this.contentEl}
/>
);
}
onClose() {
this.root.unmount();
}
}

View file

@ -0,0 +1,35 @@
import { getTagsFromNote } from "@/utils";
import { App, FuzzySuggestModal } from "obsidian";
export class TagSearchModal extends FuzzySuggestModal<string> {
constructor(
app: App,
private onChooseTag: (tag: string) => void
) {
super(app);
}
getItems(): string[] {
// Get all Markdown files in the vault.
const files = app.vault.getMarkdownFiles();
const tagSet = new Set<string>();
// Loop through each file and extract tags.
for (const file of files) {
// Retrieve the metadata cache for the file.
const tags = getTagsFromNote(file);
tags.forEach((tag) => tagSet.add(tag));
}
// Convert the set to an array.
return Array.from(tagSet);
}
getItemText(tag: string): string {
return tag;
}
onChooseItem(tag: string, evt: MouseEvent | KeyboardEvent) {
this.onChooseTag(tag);
}
}

View file

@ -9,11 +9,10 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-interactive-accent text-on-accent shadow hover:bg-interactive-accent/90",
default:
"mod-cta bg-interactive-accent text-on-accent shadow hover:bg-interactive-accent-hover",
destructive: "bg-modifier-error text-normal shadow-sm hover:bg-modifier-error/90",
outline:
"border border-border bg-primary shadow-sm hover:bg-interactive-accent hover:text-on-accent",
secondary: "bg-secondary text-normal shadow-sm hover:bg-secondary/80",
secondary: "bg-secondary text-normal shadow-sm hover:bg-interactive-hover",
ghost: "clickable-icon bg-transparent hover:bg-interactive-accent hover:text-on-accent",
link: "text-accent underline-offset-4 hover:underline",
ghost2:
@ -21,7 +20,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
sm: "h-6 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "size-7",
fit: "px-1 text-xs gap-1",

View file

@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-ring transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-interactive-accent data-[state=open]:text-muted">
<DialogPrimitive.Close className="absolute border-none right-4 top-4 text-faint clickable-icon bg-transparent hover:bg-opacity-100 hover:text-normal hover:bg-transparent outline-none focus-visible:outline-none focus-visible:text-normal focus-visible:ring-0">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@ -55,7 +55,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
<div className={cn("flex flex-col space-y-0.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
@ -73,7 +73,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn("text-lg font-semibold leading-none tracking-tight mt-0", className)}
{...props}
/>
));

View file

@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal container={activeDocument.body}>
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
container?: HTMLElement;
}
>(({ className, sideOffset = 4, container, ...props }, ref) => (
<DropdownMenuPrimitive.Portal container={container ?? activeDocument.body}>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}

View file

@ -187,7 +187,7 @@ export function SettingItem(props: SettingItemProps) {
"text-sm !shadow transition-colors",
"focus:outline-none focus:ring-1 focus:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"hover:bg-interactive-accent hover:text-on-accent"
"hover:bg-interactive-hover hover:text-normal"
)}
>
{props.placeholder && (

View file

@ -7,7 +7,12 @@ import { MD5 } from "crypto-js";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { App, Notice, TFile } from "obsidian";
import { DBOperations } from "./dbOperations";
import { extractAppIgnoreSettings, getMatchingPatterns, shouldIndexFile } from "./searchUtils";
import {
extractAppIgnoreSettings,
getMatchingPatterns,
getDecodedPatterns,
shouldIndexFile,
} from "./searchUtils";
export interface IndexingState {
isIndexingPaused: boolean;
@ -367,24 +372,25 @@ export class IndexOperations {
private updateIndexingNoticeMessage(): void {
if (this.state.indexNoticeMessage) {
const status = this.state.isIndexingPaused ? " (Paused)" : "";
const messages = [
`Copilot is indexing your vault...`,
`${this.state.indexedCount}/${this.state.totalFilesToIndex} files processed${status}`,
];
// Get the settings
const settings = getSettings();
const folders = extractAppIgnoreSettings(this.app);
// Prepare inclusion and exclusion filter messages
const inclusions = settings.qaInclusions
? `Inclusions: ${settings.qaInclusions}`
: "Inclusions: None";
const exclusions =
folders.length > 0 || settings.qaExclusions
? `Exclusions: ${folders.join(", ")}${folders.length ? ", " : ""}${settings.qaExclusions || "None"}`
: "Exclusions: None";
const inclusions = getDecodedPatterns(settings.qaInclusions);
if (inclusions.length > 0) {
messages.push(`Inclusions: ${inclusions.join(", ")}`);
}
this.state.indexNoticeMessage.textContent =
`Copilot is indexing your vault...\n` +
`${this.state.indexedCount}/${this.state.totalFilesToIndex} files processed${status}\n` +
`${exclusions}\n${inclusions}`;
const obsidianIgnoreFolders = extractAppIgnoreSettings(this.app);
const exclusions = [...obsidianIgnoreFolders, ...getDecodedPatterns(settings.qaExclusions)];
if (exclusions.length > 0) {
messages.push(`Exclusions: ${exclusions.join(", ")}`);
}
this.state.indexNoticeMessage.textContent = messages.join("\n");
}
}

View file

@ -1,6 +1,14 @@
import { TFile } from "obsidian";
import { shouldIndexFile } from "./searchUtils";
import {
categorizePatterns,
createPatternSettingsValue,
getDecodedPatterns,
getMatchingPatterns,
previewPatternValue,
shouldIndexFile,
} from "./searchUtils";
import * as utils from "@/utils";
import * as settingsModel from "@/settings/model";
// Mock Obsidian's TFile class
jest.mock("obsidian", () => ({
@ -13,6 +21,7 @@ jest.mock("obsidian", () => ({
const createTestFile = (path: string) => {
const file = new TFile();
file.path = path;
file.basename = path.split("/").pop()?.split(".")[0] || "";
return file;
};
@ -28,11 +37,18 @@ const mockApp = {
jest.mock("@/utils", () => ({
...jest.requireActual("@/utils"),
getTagsFromNote: jest.fn(),
isPathInList: jest.requireActual("@/utils").isPathInList,
stripHash: jest.requireActual("@/utils").stripHash,
}));
describe("shouldIndexFile", () => {
// Add mock for settings
jest.mock("@/settings/model", () => ({
...jest.requireActual("@/settings/model"),
getSettings: jest.fn().mockReturnValue({
qaInclusions: "",
qaExclusions: "",
}),
}));
describe("searchUtils", () => {
beforeAll(() => {
// @ts-ignore
global.app = mockApp;
@ -46,85 +62,386 @@ describe("shouldIndexFile", () => {
beforeEach(() => {
mockGetAbstractFileByPath.mockReset();
(utils.getTagsFromNote as jest.Mock).mockReset();
// Reset the settings mock before each test
(settingsModel.getSettings as jest.Mock).mockReset();
(settingsModel.getSettings as jest.Mock).mockReturnValue({
qaInclusions: "",
qaExclusions: "",
});
});
it("should return true when no inclusions or exclusions are specified", () => {
const file = createTestFile("test.md");
expect(shouldIndexFile(file, [], [])).toBe(true);
describe("shouldIndexFile", () => {
it("should return true when no inclusions or exclusions are specified", () => {
const file = createTestFile("test.md");
expect(shouldIndexFile(file, null, null)).toBe(true);
});
it("should return false when file matches exclusion pattern", () => {
const file = createTestFile("private/secret.md");
const exclusions = {
folderPatterns: ["private"],
};
expect(shouldIndexFile(file, null, exclusions)).toBe(false);
});
it("should return true when file matches inclusion pattern", () => {
const file = createTestFile("notes/important.md");
const inclusions = {
folderPatterns: ["notes"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should return false when file doesn't match inclusion pattern", () => {
const file = createTestFile("random/file.md");
const inclusions = {
folderPatterns: ["notes"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(false);
});
it("should prioritize exclusions over inclusions", () => {
const file = createTestFile("notes/private/secret.md");
const inclusions = {
folderPatterns: ["notes"],
};
const exclusions = {
folderPatterns: ["notes/private"],
};
expect(shouldIndexFile(file, inclusions, exclusions)).toBe(false);
});
it("should handle multiple inclusion patterns", () => {
const file = createTestFile("blog/post.md");
const inclusions = {
folderPatterns: ["notes", "blog", "docs"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should handle inclusion patterns with folders with slashes and spaces", () => {
const file = createTestFile("folder/with/100 spaces/post.md");
const inclusions = {
folderPatterns: ["folder/with/100 spaces"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should handle multiple exclusion patterns", () => {
const file = createTestFile("temp/draft.md");
const exclusions = {
folderPatterns: ["private", "temp", "archive"],
};
expect(shouldIndexFile(file, null, exclusions)).toBe(false);
});
it("should handle tag-based inclusion patterns", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(file);
(utils.getTagsFromNote as jest.Mock).mockReturnValue(["important", "review"]);
const inclusions = {
tagPatterns: ["#important"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should handle tag-based exclusion patterns", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(file);
(utils.getTagsFromNote as jest.Mock).mockReturnValue(["private", "draft"]);
const exclusions = {
tagPatterns: ["#private"],
};
expect(shouldIndexFile(file, null, exclusions)).toBe(false);
});
it("should handle file extension patterns in inclusions", () => {
const file = createTestFile("notes/document.pdf");
const inclusions = {
extensionPatterns: ["*.pdf"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should handle file extension patterns in exclusions", () => {
const file = createTestFile("notes/document.pdf");
const exclusions = {
extensionPatterns: ["*.pdf"],
};
expect(shouldIndexFile(file, null, exclusions)).toBe(false);
});
it("should return false when tag check fails due to file not found", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(null);
const inclusions = {
tagPatterns: ["#important"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(false);
});
it("should handle note-based inclusion patterns", () => {
const file = createTestFile("notes/referenced.md");
mockGetAbstractFileByPath.mockReturnValue(file);
const inclusions = {
notePatterns: ["[[referenced]]"],
};
expect(shouldIndexFile(file, inclusions, null)).toBe(true);
});
it("should handle note-based exclusion patterns", () => {
const file = createTestFile("notes/draft.md");
mockGetAbstractFileByPath.mockReturnValue(file);
const exclusions = {
notePatterns: ["[[draft]]"],
};
expect(shouldIndexFile(file, null, exclusions)).toBe(false);
});
});
it("should return false when file matches exclusion pattern", () => {
const file = createTestFile("private/secret.md");
const exclusions = ["private"];
expect(shouldIndexFile(file, [], exclusions)).toBe(false);
describe("categorizePatterns", () => {
it("should correctly categorize tag patterns", () => {
const patterns = ["#important", "#draft", "#review"];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
expect(tagPatterns).toEqual(patterns);
expect(extensionPatterns).toEqual([]);
expect(folderPatterns).toEqual([]);
expect(notePatterns).toEqual([]);
});
it("should correctly categorize extension patterns", () => {
const patterns = ["*.pdf", "*.md", "*.doc"];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
expect(tagPatterns).toEqual([]);
expect(extensionPatterns).toEqual(patterns);
expect(folderPatterns).toEqual([]);
expect(notePatterns).toEqual([]);
});
it("should correctly categorize folder patterns", () => {
const patterns = ["folder1", "folder2/subfolder", "documents"];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
expect(tagPatterns).toEqual([]);
expect(extensionPatterns).toEqual([]);
expect(folderPatterns).toEqual(patterns);
expect(notePatterns).toEqual([]);
});
it("should correctly categorize note patterns", () => {
const patterns = ["[[Note 1]]", "[[Important Note]]", "[[Draft]]"];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
expect(tagPatterns).toEqual([]);
expect(extensionPatterns).toEqual([]);
expect(folderPatterns).toEqual([]);
expect(notePatterns).toEqual(patterns);
});
it("should correctly categorize mixed patterns", () => {
const patterns = ["#important", "*.pdf", "folder1", "[[Note 1]]"];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
expect(tagPatterns).toEqual(["#important"]);
expect(extensionPatterns).toEqual(["*.pdf"]);
expect(folderPatterns).toEqual(["folder1"]);
expect(notePatterns).toEqual(["[[Note 1]]"]);
});
});
it("should return true when file matches inclusion pattern", () => {
const file = createTestFile("notes/important.md");
const inclusions = ["notes"];
expect(shouldIndexFile(file, inclusions, [])).toBe(true);
describe("previewPatternValue", () => {
it("should correctly preview a single pattern", () => {
const value = "folder1";
expect(previewPatternValue(value)).toBe("folder1");
});
it("should correctly preview multiple patterns", () => {
const value = "folder1,folder2,folder3";
expect(previewPatternValue(value)).toBe("folder1, folder2, folder3");
});
it("should handle encoded patterns", () => {
const value = "folder%201,folder%202,folder%203";
expect(previewPatternValue(value)).toBe("folder 1, folder 2, folder 3");
});
it("should handle empty string", () => {
expect(previewPatternValue("")).toBe("");
});
it("should handle patterns with spaces and special characters", () => {
const value = "folder%20with%20spaces,special%23chars,%23tag";
expect(previewPatternValue(value)).toBe("folder with spaces, special#chars, #tag");
});
});
it("should return false when file doesn't match inclusion pattern", () => {
const file = createTestFile("random/file.md");
const inclusions = ["notes"];
expect(shouldIndexFile(file, inclusions, [])).toBe(false);
describe("createPatternSettingsValue", () => {
it("should create settings value from single category", () => {
const result = createPatternSettingsValue({
tagPatterns: ["#important"],
extensionPatterns: [],
folderPatterns: [],
notePatterns: [],
});
expect(result).toBe("%23important");
});
it("should create settings value from multiple categories", () => {
const result = createPatternSettingsValue({
tagPatterns: ["#important"],
extensionPatterns: ["*.pdf"],
folderPatterns: ["folder1"],
notePatterns: ["[[Note 1]]"],
});
expect(result).toBe("%23important,*.pdf,%5B%5BNote%201%5D%5D,folder1");
});
it("should handle empty arrays", () => {
const result = createPatternSettingsValue({
tagPatterns: [],
extensionPatterns: [],
folderPatterns: [],
notePatterns: [],
});
expect(result).toBe("");
});
it("should properly encode special characters", () => {
const result = createPatternSettingsValue({
tagPatterns: ["#special tag"],
extensionPatterns: [],
folderPatterns: ["folder with spaces"],
notePatterns: [],
});
expect(result).toBe("%23special%20tag,folder%20with%20spaces");
});
it("should maintain pattern order", () => {
const result = createPatternSettingsValue({
tagPatterns: ["#tag1", "#tag2"],
extensionPatterns: ["*.pdf"],
folderPatterns: ["folder1"],
notePatterns: ["[[Note 1]]"],
});
expect(result).toBe("%23tag1,%23tag2,*.pdf,%5B%5BNote%201%5D%5D,folder1");
});
});
it("should prioritize exclusions over inclusions", () => {
const file = createTestFile("notes/private/secret.md");
const inclusions = ["notes"];
const exclusions = ["private"];
expect(shouldIndexFile(file, inclusions, exclusions)).toBe(false);
describe("getDecodedPatterns", () => {
it("should decode a single pattern", () => {
const value = "folder1";
expect(getDecodedPatterns(value)).toEqual(["folder1"]);
});
it("should decode multiple patterns", () => {
const value = "folder1,folder2,folder3";
expect(getDecodedPatterns(value)).toEqual(["folder1", "folder2", "folder3"]);
});
it("should handle URL encoded characters", () => {
const value = "folder%20with%20spaces,special%23chars,%23tag";
expect(getDecodedPatterns(value)).toEqual(["folder with spaces", "special#chars", "#tag"]);
});
it("should handle empty string", () => {
expect(getDecodedPatterns("")).toEqual([]);
});
it("should trim whitespace from patterns", () => {
const value = " folder1 , folder2 , folder3 ";
expect(getDecodedPatterns(value)).toEqual(["folder1", "folder2", "folder3"]);
});
it("should filter out empty patterns", () => {
const value = "folder1,,folder2, ,folder3";
expect(getDecodedPatterns(value)).toEqual(["folder1", "folder2", "folder3"]);
});
it("should handle complex patterns", () => {
const value = "%23important,%5B%5BNote%201%5D%5D,*.pdf,folder/with/100%20spaces";
expect(getDecodedPatterns(value)).toEqual([
"#important",
"[[Note 1]]",
"*.pdf",
"folder/with/100 spaces",
]);
});
});
it("should handle multiple inclusion patterns", () => {
const file = createTestFile("blog/post.md");
const inclusions = ["notes", "blog", "docs"];
expect(shouldIndexFile(file, inclusions, [])).toBe(true);
});
describe("getMatchingPatterns", () => {
it("should return null inclusions and exclusions when no patterns are set", () => {
// No need to set mock return value as it's set in beforeEach
const { inclusions, exclusions } = getMatchingPatterns();
expect(inclusions).toBeNull();
expect(exclusions).toBeNull();
});
it("should handle multiple exclusion patterns", () => {
const file = createTestFile("temp/draft.md");
const exclusions = ["private", "temp", "archive"];
expect(shouldIndexFile(file, [], exclusions)).toBe(false);
});
it("should return categorized inclusion patterns", () => {
// Mock settings with inclusions
(settingsModel.getSettings as jest.Mock).mockReturnValue({
qaInclusions: "notes,*.pdf,%23important,%5B%5BNote%201%5D%5D",
qaExclusions: "",
});
it("should handle tag-based inclusion patterns", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(file);
(utils.getTagsFromNote as jest.Mock).mockReturnValue(["important", "review"]);
const { inclusions, exclusions } = getMatchingPatterns();
expect(inclusions).toEqual({
folderPatterns: ["notes"],
extensionPatterns: ["*.pdf"],
tagPatterns: ["#important"],
notePatterns: ["[[Note 1]]"],
});
expect(exclusions).toBeNull();
});
const inclusions = ["#important"];
expect(shouldIndexFile(file, inclusions, [])).toBe(true);
});
it("should return categorized exclusion patterns", () => {
// Mock settings with exclusions
(settingsModel.getSettings as jest.Mock).mockReturnValue({
qaInclusions: "",
qaExclusions: "private,%23draft,*.tmp",
});
it("should handle tag-based exclusion patterns", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(file);
(utils.getTagsFromNote as jest.Mock).mockReturnValue(["private", "draft"]);
const { inclusions, exclusions } = getMatchingPatterns();
expect(inclusions).toBeNull();
expect(exclusions).toEqual({
folderPatterns: ["private"],
tagPatterns: ["#draft"],
extensionPatterns: ["*.tmp"],
notePatterns: [],
});
});
const exclusions = ["#private"];
expect(shouldIndexFile(file, [], exclusions)).toBe(false);
});
it("should handle both inclusions and exclusions", () => {
// Mock settings with both inclusions and exclusions
(settingsModel.getSettings as jest.Mock).mockReturnValue({
qaInclusions: "notes,%23important",
qaExclusions: "private,%23draft",
});
it("should handle file extension patterns in inclusions", () => {
const file = createTestFile("notes/document.pdf");
const inclusions = ["*.pdf"];
expect(shouldIndexFile(file, inclusions, [])).toBe(true);
});
it("should handle file extension patterns in exclusions", () => {
const file = createTestFile("notes/document.pdf");
const exclusions = ["*.pdf"];
expect(shouldIndexFile(file, [], exclusions)).toBe(false);
});
it("should return false when tag check fails due to file not found", () => {
const file = createTestFile("notes/tagged.md");
mockGetAbstractFileByPath.mockReturnValue(null);
const inclusions = ["#important"];
expect(shouldIndexFile(file, inclusions, [])).toBe(false);
const { inclusions, exclusions } = getMatchingPatterns();
expect(inclusions).toEqual({
folderPatterns: ["notes"],
tagPatterns: ["#important"],
extensionPatterns: [],
notePatterns: [],
});
expect(exclusions).toEqual({
folderPatterns: ["private"],
tagPatterns: ["#draft"],
extensionPatterns: [],
notePatterns: [],
});
});
});
});

View file

@ -1,10 +1,17 @@
import { CustomError } from "@/error";
import EmbeddingsManager from "@/LLMProviders/embeddingManager";
import { getSettings } from "@/settings/model";
import { getTagsFromNote, isPathInList, stripHash } from "@/utils";
import { getTagsFromNote, stripHash } from "@/utils";
import { Embeddings } from "@langchain/core/embeddings";
import { App, TFile } from "obsidian";
interface PatternCategory {
tagPatterns?: string[];
extensionPatterns?: string[];
folderPatterns?: string[];
notePatterns?: string[];
}
export async function getVectorLength(embeddingInstance: Embeddings | undefined): Promise<number> {
if (!embeddingInstance) {
throw new CustomError("Embedding instance not found.");
@ -45,6 +52,23 @@ export async function getAllQAMarkdownContent(app: App): Promise<string> {
return allContent;
}
/**
* Get the decoded patterns from the settings string.
* @param value - The settings string.
* @returns An array of decoded patterns.
*/
export function getDecodedPatterns(value: string): string[] {
const patterns: string[] = [];
patterns.push(
...value
.split(",")
.map((item) => decodeURIComponent(item.trim()))
.filter((item) => item.length > 0)
);
return patterns;
}
/**
* Get the exclusion patterns the exclusion settings string.
* @returns An array of exclusion patterns.
@ -53,18 +77,8 @@ function getExclusionPatterns(): string[] {
if (!getSettings().qaExclusions) {
return [];
}
const exclusions: string[] = [];
exclusions.push(...extractAppIgnoreSettings(app));
if (getSettings().qaExclusions) {
exclusions.push(
...getSettings()
.qaExclusions.split(",")
.map((item) => item.trim())
);
}
return exclusions;
return getDecodedPatterns(getSettings().qaExclusions);
}
/**
@ -75,84 +89,207 @@ function getInclusionPatterns(): string[] {
if (!getSettings().qaInclusions) {
return [];
}
const inclusions: string[] = [];
inclusions.push(
...getSettings()
.qaInclusions.split(",")
.map((item) => item.trim())
);
return inclusions;
return getDecodedPatterns(getSettings().qaInclusions);
}
/**
* Get the inclusion and exclusion patterns from the settings.
* @returns An object containing the inclusions and exclusions patterns strings.
*/
export function getMatchingPatterns(): { inclusions: string[]; exclusions: string[] } {
export function getMatchingPatterns(): {
inclusions: PatternCategory | null;
exclusions: PatternCategory | null;
} {
const inclusions = getInclusionPatterns();
const exclusions = getExclusionPatterns();
return { inclusions, exclusions };
return {
inclusions: inclusions.length > 0 ? categorizePatterns(inclusions) : null,
exclusions: exclusions.length > 0 ? categorizePatterns(exclusions) : null,
};
}
export function shouldIndexFile(file: TFile, inclusions: string[], exclusions: string[]): boolean {
if (exclusions.length > 0 && matchFilePathWithPatterns(file.path, exclusions)) {
/**
* Should index the file based on the inclusions and exclusions patterns.
* @param file - The file to check.
* @param inclusions - The inclusions patterns.
* @param exclusions - The exclusions patterns.
* @returns True if the file should be indexed, false otherwise.
*/
export function shouldIndexFile(
file: TFile,
inclusions: PatternCategory | null,
exclusions: PatternCategory | null
): boolean {
if (exclusions && matchFilePathWithPatterns(file.path, exclusions)) {
return false;
}
if (inclusions.length > 0 && !matchFilePathWithPatterns(file.path, inclusions)) {
if (inclusions && !matchFilePathWithPatterns(file.path, inclusions)) {
return false;
}
return true;
}
/**
* Break down the patterns into their respective categories.
* @param patterns - The patterns to categorize.
* @returns An object containing the categorized patterns.
*/
export function categorizePatterns(patterns: string[]) {
const tagPatterns: string[] = [];
const extensionPatterns: string[] = [];
const folderPatterns: string[] = [];
const notePatterns: string[] = [];
const tagRegex = /^#[^\s#]+$/; // Matches #tag format
const extensionRegex = /^\*\.([a-zA-Z0-9.]+)$/; // Matches *.extension format
const noteRegex = /^\[\[(.*?)\]\]$/; // Matches [[note name]] format - removed global flag and added ^ $
patterns.forEach((pattern) => {
if (tagRegex.test(pattern)) {
tagPatterns.push(pattern);
} else if (extensionRegex.test(pattern)) {
extensionPatterns.push(pattern);
} else if (noteRegex.test(pattern)) {
notePatterns.push(pattern);
} else {
folderPatterns.push(pattern);
}
});
return { tagPatterns, extensionPatterns, folderPatterns, notePatterns };
}
/**
* Convert the pattern settings value to a preview string.
* @param value - The value to preview.
* @returns The previewed value.
*/
export function previewPatternValue(value: string): string {
const patterns = getDecodedPatterns(value);
return patterns.join(", ");
}
/**
* Create the pattern settings value from the categorized patterns.
* @param tagPatterns - The tag patterns.
* @param extensionPatterns - The extension patterns.
* @param folderPatterns - The folder patterns.
* @param notePatterns - The note patterns.
* @returns The pattern settings value.
*/
export function createPatternSettingsValue({
tagPatterns,
extensionPatterns,
folderPatterns,
notePatterns,
}: PatternCategory) {
const patterns = [
...(tagPatterns ?? []),
...(extensionPatterns ?? []),
...(notePatterns ?? []),
...(folderPatterns ?? []),
].map((pattern) => encodeURIComponent(pattern));
return patterns.join(",");
}
/**
* Match the file path with the tag patterns.
* @param filePath - The file path to match.
* @param tagPatterns - The tag patterns to match the file path with.
* @returns True if the file path matches the tags, false otherwise.
*/
function matchFilePathWithTags(filePath: string, tagPatterns: string[]): boolean {
if (tagPatterns.length === 0) return false;
const file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
const tags = getTagsFromNote(file);
if (tagPatterns.some((pattern) => tags.includes(stripHash(pattern)))) {
return true;
}
}
return false;
}
/**
* Match the file path with the extension patterns.
* @param filePath - The file path to match.
* @param extensionPatterns - The extension patterns to match the file path with.
* @returns True if the file path matches the extensions, false otherwise.
*/
function matchFilePathWithExtensions(filePath: string, extensionPatterns: string[]): boolean {
if (extensionPatterns.length === 0) return false;
// Extract the file extension without the dot
const fileExtension = filePath.split(".").pop()?.toLowerCase() || "";
// Match if the file extension matches any of the patterns
return extensionPatterns.some((ext) => ext.slice(2) === fileExtension);
}
/**
* Match the file path with the folder patterns.
* @param filePath - The file path to match.
* @param folderPatterns - The folder patterns to match the file path with.
* @returns True if the file path matches the folders, false otherwise.
*/
function matchFilePathWithFolders(filePath: string, folderPatterns: string[]): boolean {
if (folderPatterns.length === 0) return false;
// Normalize path separators to forward slashes to ensure cross-platform compatibility
const normalizedFilePath = filePath.replace(/\\/g, "/");
return folderPatterns.some((pattern) => {
// Normalize pattern path separators and remove trailing slashes
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/\/$/, "");
// Check if the path starts with the pattern
return (
normalizedFilePath.startsWith(normalizedPattern) &&
// Ensure it's a proper folder match by checking for / after pattern
(normalizedFilePath.length === normalizedPattern.length ||
normalizedFilePath[normalizedPattern.length] === "/")
);
});
}
/**
* Match the file path with the note title patterns.
* @param filePath - The file path to match.
* @param notePatterns - The note patterns to match the file path with.
* @returns True if the file path matches the note titles, false otherwise.
*/
function matchFilePathWithNotes(filePath: string, noteTitles: string[]): boolean {
if (noteTitles.length === 0) return false;
const file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
if (noteTitles.some((title) => title.slice(2, -2) === file.basename)) {
return true;
}
}
return false;
}
/**
* Match the file path with the patterns.
* @param filePath - The file path to match.
* @param patterns - The patterns to match the file path with.
* @returns True if the file path matches the patterns, false otherwise.
*/
function matchFilePathWithPatterns(filePath: string, patterns: string[]): boolean {
if (!patterns || patterns.length === 0) return false;
function matchFilePathWithPatterns(filePath: string, patterns: PatternCategory): boolean {
if (!patterns) return false;
// Group patterns by type for more efficient processing
const tagPatterns: string[] = [];
const extensionPatterns: string[] = [];
const pathPatterns: string[] = [];
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } = patterns;
patterns.forEach((pattern) => {
if (pattern.startsWith("#")) {
tagPatterns.push(stripHash(pattern));
} else if (pattern.startsWith("*")) {
extensionPatterns.push(pattern.slice(1).toLowerCase());
} else {
pathPatterns.push(pattern);
}
});
// Check extension patterns
if (extensionPatterns.length > 0) {
const fileExt = filePath.toLowerCase();
if (extensionPatterns.some((ext) => fileExt.endsWith(ext))) {
return true;
}
}
// Check path patterns
if (pathPatterns.length > 0) {
if (pathPatterns.some((pattern) => isPathInList(filePath, pattern))) {
return true;
}
}
const file = app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
const tags = getTagsFromNote(file);
if (tagPatterns.some((pattern) => tags.includes(pattern))) {
return true;
}
}
return false;
return (
matchFilePathWithTags(filePath, tagPatterns ?? []) ||
matchFilePathWithExtensions(filePath, extensionPatterns ?? []) ||
matchFilePathWithFolders(filePath, folderPatterns ?? []) ||
matchFilePathWithNotes(filePath, notePatterns ?? [])
);
}
export function extractAppIgnoreSettings(app: App): string[] {

View file

@ -152,7 +152,7 @@ const SettingsMainV2: React.FC<SettingsMainV2Props> = ({ plugin }) => {
</span>
</div>
<div className="self-end sm:self-auto">
<Button variant="outline" size="sm" onClick={handleReset}>
<Button variant="secondary" size="sm" onClick={handleReset}>
Reset Settings
</Button>
</div>

View file

@ -156,15 +156,12 @@ const ApiKeyDialog: React.FC<ApiKeyDialogProps> = ({
<Button
onClick={() => verifyApiKey(item.provider, item.apiKey)}
disabled={!item.apiKey || verifyingProviders.size > 0}
variant="outline"
variant="secondary"
size="sm"
className="w-full whitespace-nowrap"
>
{verifyingProviders.has(item.provider) ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verify
</>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
"Verify"
)}

View file

@ -137,7 +137,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
>
<Button
onClick={() => setIsApiKeyDialogOpen(true)}
variant="outline"
variant="secondary"
className="flex items-center gap-2 w-full sm:w-auto justify-center sm:justify-start"
>
Set Keys
@ -363,8 +363,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
<Button
onClick={() => applyCustomNoteFormat()}
disabled={isChecking}
variant="outline"
size="sm"
variant="secondary"
>
{isChecking ? (
<>
@ -410,7 +409,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ indexVaultToVectorStore }
description="Enable or disable builtin Copilot commands"
dialogTitle="Command Settings"
dialogDescription="Enable or disable chat commands"
trigger={<Button variant="outline">Manage Commands</Button>}
trigger={<Button variant="secondary">Manage Commands</Button>}
>
<div className="h-[50vh] sm:h-[400px] overflow-y-auto px-1 py-2">
<div className="space-y-4">

View file

@ -543,10 +543,10 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
</Label>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleAdd} disabled={isButtonDisabled()}>
<Button variant="secondary" onClick={handleAdd} disabled={isButtonDisabled()}>
Add Model
</Button>
<Button variant="outline" onClick={handleVerify} disabled={isButtonDisabled()}>
<Button variant="secondary" onClick={handleVerify} disabled={isButtonDisabled()}>
{isVerifying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

View file

@ -502,7 +502,7 @@ export const ModelTable: React.FC<ModelTableProps> = ({
{renderMobileView()}
<div className="mt-4 flex justify-end">
<Button onClick={onAdd} variant="outline" className="flex items-center gap-2">
<Button onClick={onAdd} variant="secondary" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Custom Model
</Button>

View file

@ -66,7 +66,7 @@ export function PlusSettings() {
>
{isChecking ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply"}
</Button>
<Button variant="default" onClick={() => navigateToPlusPage(PLUS_UTM_MEDIUMS.SETTINGS)}>
<Button variant="secondary" onClick={() => navigateToPlusPage(PLUS_UTM_MEDIUMS.SETTINGS)}>
Join Now <ExternalLink className="size-4" />
</Button>
</div>

View file

@ -1,8 +1,9 @@
import { PatternMatchingModal } from "@/components/modals/PatternMatchingModal";
import { RebuildIndexConfirmModal } from "@/components/modals/RebuildIndexConfirmModal";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { SettingItem } from "@/components/ui/setting-item";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { VAULT_VECTOR_STORE_STRATEGIES } from "@/constants";
import { useTab } from "@/contexts/TabContext";
import { updateSetting, useSettingsValue } from "@/settings/model";
import { HelpCircle } from "lucide-react";
import React from "react";
@ -12,21 +13,7 @@ interface QASettingsProps {
}
const QASettings: React.FC<QASettingsProps> = ({ indexVaultToVectorStore }) => {
const { modalContainer } = useTab();
const settings = useSettingsValue();
const [openPopoverIds, setOpenPopoverIds] = React.useState<Set<string>>(new Set());
const handlePopoverOpen = (id: string) => {
setOpenPopoverIds((prev) => new Set([...prev, id]));
};
const handlePopoverClose = (id: string) => {
setOpenPopoverIds((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
};
const handlePartitionsChange = (value: string) => {
const numValue = parseInt(value);
@ -49,62 +36,47 @@ const QASettings: React.FC<QASettingsProps> = ({ indexVaultToVectorStore }) => {
description={
<div className="flex items-center gap-1.5">
<span className="leading-none">Decide when you want the vault to be indexed.</span>
<Popover
open={openPopoverIds.has("index-help")}
onOpenChange={(open) => {
if (open) {
handlePopoverOpen("index-help");
} else {
handlePopoverClose("index-help");
}
}}
>
<PopoverTrigger asChild>
<HelpCircle
className="h-5 w-5 sm:h-4 sm:w-4 cursor-pointer text-muted hover:text-accent translate-y-[1px]"
onMouseEnter={() => handlePopoverOpen("index-help")}
onMouseLeave={() => handlePopoverClose("index-help")}
/>
</PopoverTrigger>
<PopoverContent
container={modalContainer}
className="w-[90vw] max-w-[400px] p-2 sm:p-3"
side="bottom"
align="center"
sideOffset={0}
onMouseEnter={() => handlePopoverOpen("index-help")}
onMouseLeave={() => handlePopoverClose("index-help")}
>
<div className="space-y-2 sm:space-y-2.5">
<div className="rounded bg-callout-warning/10 p-1.5 sm:p-2 ring-ring">
<p className="text-callout-warning text-xs sm:text-sm">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4" />
</TooltipTrigger>
<TooltipContent>
<div className="space-y-2 py-2">
<div className="space-y-1">
<div className="text-muted text-sm">Choose when to index your vault:</div>
<ul className="space-y-1 pl-2 list-disc text-sm">
<li>
<div className="flex items-center gap-1">
<strong className="inline-block whitespace-nowrap">NEVER:</strong>
<span>Manual indexing via command or refresh only</span>
</div>
</li>
<li>
<div className="flex items-center gap-1">
<strong className="inline-block whitespace-nowrap">
ON STARTUP:
</strong>
<span>Index updates when plugin loads or reloads</span>
</div>
</li>
<li>
<div className="flex items-center gap-1">
<strong className="inline-block whitespace-nowrap">
ON MODE SWITCH:
</strong>
<span>Updates when entering QA mode (Recommended)</span>
</div>
</li>
</ul>
</div>
<p className="text-callout-warning text-sm">
Warning: Cost implications for large vaults with paid models
</p>
</div>
<div className="space-y-1 sm:space-y-1.5">
<p className="text-muted text-[11px] sm:text-xs">
Choose when to index your vault:
</p>
<ul className="space-y-1 pl-2 sm:pl-3 list-disc text-[11px] sm:text-xs">
<li>
<strong className="inline-block whitespace-nowrap">NEVER</strong>
<span>Manual indexing via command or refresh only</span>
</li>
<li>
<strong className="inline-block whitespace-nowrap">ON STARTUP</strong>
<span>Index updates when plugin loads or reloads</span>
</li>
<li>
<strong className="inline-block whitespace-nowrap">
ON MODE SWITCH
</strong>
<span>Updates when entering QA mode (Recommended)</span>
</li>
</ul>
</div>
</div>
</PopoverContent>
</Popover>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
value={settings.indexVaultToVectorStore}
@ -186,35 +158,58 @@ const QASettings: React.FC<QASettingsProps> = ({ indexVaultToVectorStore }) => {
{/* Exclusions */}
<SettingItem
type="textarea"
type="custom"
title="Exclusions"
description={
<>
<p>
Comma separated list of paths, tags, note titles or file extension will be
excluded from the indexing process. e.g. folder1, folder1/folder2, #tag1, #tag2,
[[note1]], [[note2]], *.jpg, *.excallidraw.md etc,
Exclude folders, tags, note titles or file extensions from being indexed.
Previously indexed files will remain until a force re-index is performed.
</p>
<em>
NOTE: Tags must be in the note properties, not the note content. Files which were
previously indexed will remain in the index unless you force re-index.
</em>
</>
}
value={settings.qaExclusions}
onChange={(value) => updateSetting("qaExclusions", value)}
placeholder="folder1, folder1/folder2, #tag1, #tag2, [[note1]], [[note2]], *.jpg, *.excallidraw.md"
/>
>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value) => updateSetting("qaExclusions", value),
settings.qaExclusions,
"Manage Exclusions"
).open()
}
>
Manage
</Button>
</SettingItem>
{/* Inclusions */}
<SettingItem
type="textarea"
type="custom"
title="Inclusions"
description="When specified, ONLY these paths, tags, or note titles will be indexed (comma separated). Takes precedence over exclusions. Files which were previously indexed will remain in the index unless you force re-index. Format: folder1, folder1/folder2, #tag1, #tag2, [[note1]], [[note2]]"
value={settings.qaInclusions}
onChange={(value) => updateSetting("qaInclusions", value)}
placeholder="folder1, #tag1, [[note1]]"
/>
description={
<p>
Index only the specified paths, tags, or note titles. Exclusions take precedence
over inclusions. Previously indexed files will remain until a force re-index is
performed.
</p>
}
>
<Button
variant="secondary"
onClick={() =>
new PatternMatchingModal(
app,
(value) => updateSetting("qaInclusions", value),
settings.qaInclusions,
"Manage Inclusions"
).open()
}
>
Manage
</Button>
</SettingItem>
{/* Enable Obsidian Sync */}
<SettingItem

View file

@ -1,5 +1,5 @@
import { DateTime } from "luxon";
import { getTimeRangeMsTool } from "../src/tools/TimeTools";
import { getTimeRangeMsTool } from "./TimeTools";
// Helper function to extract the tool function
const getTimeRangeMs = async (timeExpression: string) => {

View file

@ -5,9 +5,8 @@ import {
getNotesFromPath,
getNotesFromTags,
isFolderMatch,
isPathInList,
processVariableNameForNotePath,
} from "../src/utils";
} from "./utils";
// Mock Obsidian's TFile class
jest.mock("obsidian", () => ({
@ -293,56 +292,6 @@ describe("getNotesFromTags", () => {
});
});
describe("isPathInList", () => {
it("should exclude a file path that exactly matches an excluded path", () => {
const result = isPathInList("test/folder/note.md", "test/folder");
expect(result).toBe(true);
});
it("should not exclude a file path if there is no match", () => {
const result = isPathInList("test/folder/note.md", "another/folder");
expect(result).toBe(false);
});
it("should exclude a file path that matches an excluded path with leading slash", () => {
const result = isPathInList("test/folder/note.md", "/test/folder");
expect(result).toBe(true);
});
it("should exclude a note title that matches an excluded path with surrounding [[ and ]]", () => {
const result = isPathInList("test/folder/note1.md", "[[note1]]");
expect(result).toBe(true);
});
it("should be case insensitive when excluding a file path", () => {
const result = isPathInList("Test/Folder/Note.md", "test/folder");
expect(result).toBe(true);
});
it("should handle multiple excluded paths separated by commas", () => {
const result = isPathInList("test/folder/note.md", "another/folder,test/folder");
expect(result).toBe(true);
});
it("should not exclude a file path if it partially matches an excluded path without proper segmentation", () => {
const result = isPathInList("test/folder123/note.md", "test/folder");
expect(result).toBe(false);
});
it("should exclude a file path that matches any one of multiple excluded paths", () => {
const result = isPathInList(
"test/folder/note.md",
"another/folder, test/folder, yet/another/folder"
);
expect(result).toBe(true);
});
it("should trim spaces around excluded paths", () => {
const result = isPathInList("test/folder/note.md", " another/folder , test/folder ");
expect(result).toBe(true);
});
});
describe("extractNoteTitles", () => {
it("should extract single note title", () => {
const query = "Please refer to [[Note1]] for more information.";

View file

@ -207,39 +207,6 @@ export function getNotesFromTags(vault: Vault, tags: string[], noteFiles?: TFile
return filesWithTag;
}
export function isPathInList(filePath: string, pathList: string): boolean {
if (!pathList) return false;
// Extract the file name from the filePath
const fileName = filePath.split("/").pop()?.toLowerCase();
// Normalize the file path for case-insensitive comparison
const normalizedFilePath = filePath.toLowerCase();
return pathList
.split(",")
.map(
(path) =>
path
.trim() // Trim whitespace
.replace(/^\[\[|\]\]$/g, "") // Remove surrounding [[ and ]]
.replace(/^\//, "") // Remove leading slash
.toLowerCase() // Convert to lowercase for case-insensitive comparison
)
.some((normalizedPath) => {
// Check for exact match or proper segmentation
const isExactMatch =
normalizedFilePath === normalizedPath ||
normalizedFilePath.startsWith(normalizedPath + "/") ||
normalizedFilePath.endsWith("/" + normalizedPath) ||
normalizedFilePath.includes("/" + normalizedPath + "/");
// Check for file name match (for cases like [[note1]])
const isFileNameMatch = fileName === normalizedPath + ".md";
return isExactMatch || isFileNameMatch;
});
}
export const stringToChainType = (chain: string): ChainType => {
switch (chain) {
case "llm_chain":