mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Enhance inclusion/exclusion patterns settings (#1261)
This commit is contained in:
parent
17f773ae62
commit
2c99284da9
28 changed files with 1288 additions and 364 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
src/components/modals/CustomPatternInputModal.tsx
Normal file
83
src/components/modals/CustomPatternInputModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
92
src/components/modals/ExtensionInputModal.tsx
Normal file
92
src/components/modals/ExtensionInputModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
39
src/components/modals/FolderSearchModal.tsx
Normal file
39
src/components/modals/FolderSearchModal.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
289
src/components/modals/PatternMatchingModal.tsx
Normal file
289
src/components/modals/PatternMatchingModal.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
35
src/components/modals/TagSearchModal.tsx
Normal file
35
src/components/modals/TagSearchModal.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
@ -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.";
|
||||
33
src/utils.ts
33
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":
|
||||
|
|
|
|||
Loading…
Reference in a new issue