From 2c99284da9bfe92290b94603f54b534ea0ba32e9 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Tue, 18 Feb 2025 23:01:20 -0800 Subject: [PATCH] Enhance inclusion/exclusion patterns settings (#1261) --- jest.config.js | 4 +- src/components/modals/AddContextNoteModal.tsx | 36 +- src/components/modals/AddPromptModal.tsx | 2 +- src/components/modals/BaseNoteModal.tsx | 16 +- .../modals/CustomPatternInputModal.tsx | 83 ++++ src/components/modals/ExtensionInputModal.tsx | 92 ++++ src/components/modals/FolderSearchModal.tsx | 39 ++ .../modals/PatternMatchingModal.tsx | 289 +++++++++++ src/components/modals/TagSearchModal.tsx | 35 ++ src/components/ui/button.tsx | 9 +- src/components/ui/dialog.tsx | 6 +- src/components/ui/dropdown-menu.tsx | 8 +- src/components/ui/setting-item.tsx | 2 +- {tests => src}/customPromptProcessor.test.ts | 0 {tests => src}/encryptionService.test.ts | 0 src/search/indexOperations.ts | 36 +- src/search/searchUtils.test.ts | 449 +++++++++++++++--- src/search/searchUtils.ts | 265 ++++++++--- src/settings/v2/SettingsMainV2.tsx | 2 +- src/settings/v2/components/ApiKeyDialog.tsx | 7 +- src/settings/v2/components/BasicSettings.tsx | 7 +- src/settings/v2/components/ModelAddDialog.tsx | 4 +- src/settings/v2/components/ModelTable.tsx | 2 +- src/settings/v2/components/PlusSettings.tsx | 2 +- src/settings/v2/components/QASettings.tsx | 169 ++++--- {tests => src/tools}/TimeTools.test.ts | 2 +- {tests => src}/utils.test.ts | 53 +-- src/utils.ts | 33 -- 28 files changed, 1288 insertions(+), 364 deletions(-) create mode 100644 src/components/modals/CustomPatternInputModal.tsx create mode 100644 src/components/modals/ExtensionInputModal.tsx create mode 100644 src/components/modals/FolderSearchModal.tsx create mode 100644 src/components/modals/PatternMatchingModal.tsx create mode 100644 src/components/modals/TagSearchModal.tsx rename {tests => src}/customPromptProcessor.test.ts (100%) rename {tests => src}/encryptionService.test.ts (100%) rename {tests => src/tools}/TimeTools.test.ts (99%) rename {tests => src}/utils.test.ts (85%) diff --git a/jest.config.js b/jest.config.js index a39d024d..015e9365 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", - roots: ["/src", "/tests"], + roots: ["/src"], transform: { "^.+\\.(js|jsx|ts|tsx)$": "ts-jest", }, @@ -10,7 +10,7 @@ module.exports = { "^@/(.*)$": "/src/$1", "^obsidian$": "/__mocks__/obsidian.js", }, - testRegex: "(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + testRegex: ".*\\.test\\.(jsx?|tsx?)$", moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], testPathIgnorePatterns: ["/node_modules/"], setupFiles: ["/jest.setup.js"], diff --git a/src/components/modals/AddContextNoteModal.tsx b/src/components/modals/AddContextNoteModal.tsx index 9ade2b5e..c9356e76 100644 --- a/src/components/modals/AddContextNoteModal.tsx +++ b/src/components/modals/AddContextNoteModal.tsx @@ -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 { 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(); + 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 { onChooseItem(note: TFile, evt: MouseEvent | KeyboardEvent) { this.onNoteSelect(note); } + + renderSuggestion(match: FuzzyMatch, 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); + } + } + } } diff --git a/src/components/modals/AddPromptModal.tsx b/src/components/modals/AddPromptModal.tsx index 1c1ec029..4f87a771 100644 --- a/src/components/modals/AddPromptModal.tsx +++ b/src/components/modals/AddPromptModal.tsx @@ -146,7 +146,7 @@ function AddPromptModalContent({
- + +
+ + ); +} + +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( + + ); + } + + onClose() { + this.root.unmount(); + } +} diff --git a/src/components/modals/ExtensionInputModal.tsx b/src/components/modals/ExtensionInputModal.tsx new file mode 100644 index 00000000..65b21435 --- /dev/null +++ b/src/components/modals/ExtensionInputModal.tsx @@ -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(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 ( +
+
+ { + setExtension(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + /> + {error &&

{error}

} +
+
+ + +
+
+ ); +} + +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( + + ); + } + + onClose() { + this.root.unmount(); + } +} diff --git a/src/components/modals/FolderSearchModal.tsx b/src/components/modals/FolderSearchModal.tsx new file mode 100644 index 00000000..5ce68216 --- /dev/null +++ b/src/components/modals/FolderSearchModal.tsx @@ -0,0 +1,39 @@ +import { App, FuzzySuggestModal } from "obsidian"; +import { extractAppIgnoreSettings } from "@/search/searchUtils"; + +export class FolderSearchModal extends FuzzySuggestModal { + constructor( + app: App, + private onChooseFolder: (folder: string) => void + ) { + super(app); + } + + getItems(): string[] { + const folderSet = new Set(); + 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); + } +} diff --git a/src/components/modals/PatternMatchingModal.tsx b/src/components/modals/PatternMatchingModal.tsx new file mode 100644 index 00000000..35d837ae --- /dev/null +++ b/src/components/modals/PatternMatchingModal.tsx @@ -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 ( +
+
{title}
+
    + {patterns.map((pattern) => ( +
  • + {pattern} + +
  • + ))} +
+
+ ); +} + +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 ( +
+
+ {!hasValue && ( +
No patterns specified
+ )} + {tagPatterns.length > 0 && ( + { + const newPatterns = tagPatterns.filter((p) => p !== pattern); + updateCategories({ + tagPatterns: newPatterns, + }); + }} + /> + )} + {extensionPatterns.length > 0 && ( + { + const newPatterns = extensionPatterns.filter((p) => p !== pattern); + updateCategories({ + extensionPatterns: newPatterns, + }); + }} + /> + )} + {folderPatterns.length > 0 && ( + { + const newPatterns = folderPatterns.filter((p) => p !== pattern); + updateCategories({ + folderPatterns: newPatterns, + }); + }} + /> + )} + {notePatterns.length > 0 && ( + { + const newPatterns = notePatterns.filter((p) => p !== pattern); + updateCategories({ + notePatterns: newPatterns, + }); + }} + /> + )} +
+
+ + + + + + { + new TagSearchModal(app, (tag) => { + const tagPattern = `#${tag}`; + if (tagPatterns.includes(tagPattern)) { + return; + } + updateCategories({ + tagPatterns: [...tagPatterns, tagPattern], + }); + }).open(); + }} + > +
+ + Tag +
+
+ { + new FolderSearchModal(app, (folder) => { + if (folderPatterns.includes(folder)) { + return; + } + updateCategories({ + folderPatterns: [...folderPatterns, folder], + }); + }).open(); + }} + > +
+ + Folder +
+
+ { + new AddContextNoteModal({ + app, + onNoteSelect: (note) => { + const notePattern = `[[${note.basename}]]`; + if (notePatterns.includes(notePattern)) { + return; + } + updateCategories({ + notePatterns: [...notePatterns, notePattern], + }); + }, + excludeNotePaths: [], + titleOnly: true, + }).open(); + }} + > +
+ + Note +
+
+ { + new ExtensionInputModal(app, (extension) => { + const extensionPattern = `*.${extension}`; + if (extensionPatterns.includes(extensionPattern)) { + return; + } + updateCategories({ + extensionPatterns: [...extensionPatterns, extensionPattern], + }); + }).open(); + }} + > +
+ + Extension +
+
+ { + 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(); + }} + > +
+ + Custom +
+
+
+
+
+
+ ); +} + +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( + + ); + } + + onClose() { + this.root.unmount(); + } +} diff --git a/src/components/modals/TagSearchModal.tsx b/src/components/modals/TagSearchModal.tsx new file mode 100644 index 00000000..4f233f4a --- /dev/null +++ b/src/components/modals/TagSearchModal.tsx @@ -0,0 +1,35 @@ +import { getTagsFromNote } from "@/utils"; +import { App, FuzzySuggestModal } from "obsidian"; + +export class TagSearchModal extends FuzzySuggestModal { + 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(); + + // 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); + } +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0effa386..097520f1 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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", diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 2a2506ce..9539196b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -45,7 +45,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close @@ -55,7 +55,7 @@ const DialogContent = React.forwardRef< DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogHeader.displayName = "DialogHeader"; @@ -73,7 +73,7 @@ const DialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 4f11226c..f177ddec 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + container?: HTMLElement; + } +>(({ className, sideOffset = 4, container, ...props }, ref) => ( + {props.placeholder && ( diff --git a/tests/customPromptProcessor.test.ts b/src/customPromptProcessor.test.ts similarity index 100% rename from tests/customPromptProcessor.test.ts rename to src/customPromptProcessor.test.ts diff --git a/tests/encryptionService.test.ts b/src/encryptionService.test.ts similarity index 100% rename from tests/encryptionService.test.ts rename to src/encryptionService.test.ts diff --git a/src/search/indexOperations.ts b/src/search/indexOperations.ts index f4739b96..547c4ac9 100644 --- a/src/search/indexOperations.ts +++ b/src/search/indexOperations.ts @@ -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"); } } diff --git a/src/search/searchUtils.test.ts b/src/search/searchUtils.test.ts index 59333386..91c76adb 100644 --- a/src/search/searchUtils.test.ts +++ b/src/search/searchUtils.test.ts @@ -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: [], + }); + }); }); }); diff --git a/src/search/searchUtils.ts b/src/search/searchUtils.ts index 095c7844..1097e52d 100644 --- a/src/search/searchUtils.ts +++ b/src/search/searchUtils.ts @@ -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 { if (!embeddingInstance) { throw new CustomError("Embedding instance not found."); @@ -45,6 +52,23 @@ export async function getAllQAMarkdownContent(app: App): Promise { 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[] { diff --git a/src/settings/v2/SettingsMainV2.tsx b/src/settings/v2/SettingsMainV2.tsx index f1a1c791..448fec44 100644 --- a/src/settings/v2/SettingsMainV2.tsx +++ b/src/settings/v2/SettingsMainV2.tsx @@ -152,7 +152,7 @@ const SettingsMainV2: React.FC = ({ plugin }) => {
-
diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index e7ec66f1..59d7a155 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -156,15 +156,12 @@ const ApiKeyDialog: React.FC = ({ } + trigger={} >
diff --git a/src/settings/v2/components/ModelAddDialog.tsx b/src/settings/v2/components/ModelAddDialog.tsx index 69337781..d3aa3888 100644 --- a/src/settings/v2/components/ModelAddDialog.tsx +++ b/src/settings/v2/components/ModelAddDialog.tsx @@ -543,10 +543,10 @@ export const ModelAddDialog: React.FC = ({
- - diff --git a/src/settings/v2/components/PlusSettings.tsx b/src/settings/v2/components/PlusSettings.tsx index cbf8f1ba..0edd3694 100644 --- a/src/settings/v2/components/PlusSettings.tsx +++ b/src/settings/v2/components/PlusSettings.tsx @@ -66,7 +66,7 @@ export function PlusSettings() { > {isChecking ? : "Apply"} -
diff --git a/src/settings/v2/components/QASettings.tsx b/src/settings/v2/components/QASettings.tsx index 6a383321..4135c7e5 100644 --- a/src/settings/v2/components/QASettings.tsx +++ b/src/settings/v2/components/QASettings.tsx @@ -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 = ({ indexVaultToVectorStore }) => { - const { modalContainer } = useTab(); const settings = useSettingsValue(); - const [openPopoverIds, setOpenPopoverIds] = React.useState>(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 = ({ indexVaultToVectorStore }) => { description={
Decide when you want the vault to be indexed. - { - if (open) { - handlePopoverOpen("index-help"); - } else { - handlePopoverClose("index-help"); - } - }} - > - - handlePopoverOpen("index-help")} - onMouseLeave={() => handlePopoverClose("index-help")} - /> - - handlePopoverOpen("index-help")} - onMouseLeave={() => handlePopoverClose("index-help")} - > -
-
-

+ + + + + + +

+
+
Choose when to index your vault:
+
    +
  • +
    + NEVER: + Manual indexing via command or refresh only +
    +
  • +
  • +
    + + ON STARTUP: + + Index updates when plugin loads or reloads +
    +
  • +
  • +
    + + ON MODE SWITCH: + + Updates when entering QA mode (Recommended) +
    +
  • +
+
+

Warning: Cost implications for large vaults with paid models

-
-

- Choose when to index your vault: -

-
    -
  • - NEVER: - Manual indexing via command or refresh only -
  • -
  • - ON STARTUP: - Index updates when plugin loads or reloads -
  • -
  • - - ON MODE SWITCH: - - Updates when entering QA mode (Recommended) -
  • -
-
-
- - + + +
} value={settings.indexVaultToVectorStore} @@ -186,35 +158,58 @@ const QASettings: React.FC = ({ indexVaultToVectorStore }) => { {/* Exclusions */}

- 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.

- - 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. - } - value={settings.qaExclusions} - onChange={(value) => updateSetting("qaExclusions", value)} - placeholder="folder1, folder1/folder2, #tag1, #tag2, [[note1]], [[note2]], *.jpg, *.excallidraw.md" - /> + > + +
{/* Inclusions */} updateSetting("qaInclusions", value)} - placeholder="folder1, #tag1, [[note1]]" - /> + description={ +

+ 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. +

+ } + > + +
{/* Enable Obsidian Sync */} { diff --git a/tests/utils.test.ts b/src/utils.test.ts similarity index 85% rename from tests/utils.test.ts rename to src/utils.test.ts index be65e850..2eefe2e7 100644 --- a/tests/utils.test.ts +++ b/src/utils.test.ts @@ -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."; diff --git a/src/utils.ts b/src/utils.ts index ae1caa27..9b63da2a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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":