chore: update Angular TypeScript build info cache
This commit is contained in:
+68
-27
@@ -130,43 +130,47 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-obs-l-border/60 bg-obs-l-bg-main/75 px-3 py-3 shadow-inner dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
aria-label="Afficher les fichiers" title="Fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
aria-label="Ouvrir la recherche" title="Recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
aria-label="Afficher les tags" title="Tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'graph' }"
|
||||
aria-label="Afficher la vue graphe" title="Graph View"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-obs-l-text-muted transition duration-150 hover:border-obs-l-border/50 hover:bg-obs-l-bg-main/60 hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:border-obs-d-border/50 dark:hover:bg-obs-d-bg-main/60 dark:hover:text-obs-d-text-main"
|
||||
[ngClass]="{ 'bg-obs-l-bg-main/80 text-obs-l-text-main shadow-sm dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-main': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
aria-label="Afficher l'agenda" title="Agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -524,23 +528,60 @@
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-obs-l-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-obs-l-text-muted dark:border-obs-d-border dark:text-obs-d-text-muted lg:block">
|
||||
Outline
|
||||
<!-- Outline/Settings inline header -->
|
||||
<div class="border-b border-obs-l-border px-3 py-2 dark:border-obs-d-border">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setOutlineTab('outline')"
|
||||
(keydown.enter)="setOutlineTab('outline')"
|
||||
(keydown.space)="setOutlineTab('outline'); $event.preventDefault()"
|
||||
[class.opacity-100]="outlineTab() === 'outline'"
|
||||
[class.opacity-60]="outlineTab() !== 'outline'"
|
||||
class="rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
aria-label="Afficher Outline"
|
||||
[attr.aria-pressed]="outlineTab() === 'outline'"
|
||||
title="Outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setOutlineTab('settings')"
|
||||
(keydown.enter)="setOutlineTab('settings')"
|
||||
(keydown.space)="setOutlineTab('settings'); $event.preventDefault()"
|
||||
[class.opacity-100]="outlineTab() === 'settings'"
|
||||
[class.opacity-60]="outlineTab() !== 'settings'"
|
||||
class="rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
aria-label="Ouvrir les réglages du graphe"
|
||||
[attr.aria-pressed]="outlineTab() === 'settings'"
|
||||
title="Graph-View Settings">
|
||||
<!-- gear/network icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">{{ outlineTab() | titlecase }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (outlineTab() === 'outline') {
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight transition hover:text-obs-l-text-main dark:hover:text-obs-d-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">Aucun titre dans cette note.</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="text-sm italic text-obs-l-text-muted dark:text-obs-d-text-muted">Aucun titre dans cette note.</p>
|
||||
<app-graph-inline-settings (animateRequested)="onAnimateRequested?.()"></app-graph-inline-settings>
|
||||
}
|
||||
</div>
|
||||
<div class="border-t border-obs-l-border bg-obs-l-bg-secondary/60 p-4 dark:border-obs-d-border dark:bg-obs-d-bg-secondary/60">
|
||||
|
||||
@@ -118,53 +118,56 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-border/70 bg-card/85 px-3 py-3 shadow-subtle">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
<button
|
||||
(click)="setView('files')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'files' }"
|
||||
aria-label="Afficher les fichiers"
|
||||
aria-label="Afficher les fichiers" title="Fichiers"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||
<span>Fichiers</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('search')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'search' }"
|
||||
aria-label="Ouvrir la recherche"
|
||||
aria-label="Ouvrir la recherche" title="Recherche"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<span>Recherche</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('tags')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'tags' }"
|
||||
aria-label="Afficher les tags"
|
||||
aria-label="Afficher les tags" title="Tags"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-5 5a2 2 0 01-2.828 0l-7-7A2 2 0 013 8V3z" /></svg>
|
||||
<span>Tags</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('graph')"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'graph' }"
|
||||
aria-label="Afficher la vue graphe" title="Graph View"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('calendar')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'calendar' }"
|
||||
[attr.aria-pressed]="activeView() === 'calendar'"
|
||||
aria-label="Afficher l'agenda"
|
||||
aria-label="Afficher l'agenda" title="Agenda"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<span>Agenda</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="setView('bookmarks')"
|
||||
class="group flex flex-col items-center gap-1 rounded-xl border border-transparent px-2.5 py-2 text-xs font-medium text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
class="group flex items-center justify-center rounded-xl border border-transparent px-2.5 py-2 text-text-muted transition duration-150 hover:border-border/60 hover:bg-bg-muted/70 hover:text-text-main"
|
||||
[ngClass]="{ 'bg-bg-muted/80 text-text-main shadow-subtle': activeView() === 'bookmarks' }"
|
||||
[attr.aria-pressed]="activeView() === 'bookmarks'"
|
||||
aria-label="Afficher les favoris"
|
||||
aria-label="Afficher les favoris" title="Favoris"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
<span>Favoris</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,13 +189,14 @@
|
||||
<div class="space-y-4 p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[ngModel]="sidebarSearchTerm()"
|
||||
(ngModelChange)="updateSearchTerm($event)"
|
||||
class="input"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
<app-search-input-with-assistant
|
||||
[value]="sidebarSearchTerm()"
|
||||
(valueChange)="updateSearchTerm($event)"
|
||||
(submit)="onSearchSubmit($event)"
|
||||
[placeholder]="'Rechercher dans la voûte...'"
|
||||
[context]="'vault-sidebar'"
|
||||
[showSearchIcon]="false"
|
||||
[inputClass]="'input'"
|
||||
/>
|
||||
</div>
|
||||
@if (activeTagDisplay(); as tagDisplay) {
|
||||
@@ -493,14 +497,14 @@
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<div class="relative w-full flex-1 min-w-0">
|
||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher dans la voûte..."
|
||||
[ngModel]="sidebarSearchTerm()"
|
||||
(ngModelChange)="updateSearchTerm($event, true)"
|
||||
class="w-full rounded-full border border-border bg-bg-muted/70 pl-11 pr-4 py-2.5 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Rechercher dans la voûte"
|
||||
<app-search-input-with-assistant
|
||||
[value]="sidebarSearchTerm()"
|
||||
(valueChange)="updateSearchTerm($event, true)"
|
||||
(submit)="onSearchSubmit($event)"
|
||||
[placeholder]="'Rechercher dans la voûte...'"
|
||||
[context]="'vault-header'"
|
||||
[showSearchIcon]="true"
|
||||
[inputClass]="'w-full rounded-full border border-border bg-bg-muted/70 pl-11 pr-4 py-2.5 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring'"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -583,23 +587,57 @@
|
||||
<p class="mt-1 text-xs uppercase tracking-wide text-text-muted">Table des matières & calendrier</p>
|
||||
</div>
|
||||
}
|
||||
<div class="hidden border-b border-border px-4 py-3 text-sm font-semibold uppercase tracking-wide text-text-muted lg:block">
|
||||
Outline
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setOutlineTab('outline')"
|
||||
(keydown.enter)="setOutlineTab('outline')"
|
||||
(keydown.space)="setOutlineTab('outline'); $event.preventDefault()"
|
||||
[class.opacity-100]="outlineTab() === 'outline'"
|
||||
[class.opacity-60]="outlineTab() !== 'outline'"
|
||||
class="rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Afficher Outline"
|
||||
[attr.aria-pressed]="outlineTab() === 'outline'"
|
||||
title="Outline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setOutlineTab('settings')"
|
||||
(keydown.enter)="setOutlineTab('settings')"
|
||||
(keydown.space)="setOutlineTab('settings'); $event.preventDefault()"
|
||||
[class.opacity-100]="outlineTab() === 'settings'"
|
||||
[class.opacity-60]="outlineTab() !== 'settings'"
|
||||
class="rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="Ouvrir les réglages du graphe"
|
||||
[attr.aria-pressed]="outlineTab() === 'settings'"
|
||||
title="Graph-View Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-text-muted">{{ outlineTab() | titlecase }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight text-text-muted transition hover:text-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (outlineTab() === 'outline') {
|
||||
@if (tableOfContents().length > 0) {
|
||||
<ul class="space-y-2">
|
||||
@for (entry of tableOfContents(); track entry.id) {
|
||||
<li [style.padding-left.rem]="(entry.level - 1) * 0.75" class="flex items-start gap-2 text-sm text-text-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mt-1 h-3 w-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||
<a (click)="scrollToHeading(entry.id)" class="cursor-pointer leading-tight text-text-muted transition hover:text-text-main">
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="text-sm italic text-text-muted">Aucun titre dans cette note.</p>
|
||||
}
|
||||
} @else {
|
||||
<p class="text-sm italic text-text-muted">Aucun titre dans cette note.</p>
|
||||
<app-graph-inline-settings></app-graph-inline-settings>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,14 @@ import { NoteViewerComponent, WikiLinkActivation } from './components/tags-view/
|
||||
import { GraphViewComponent } from './components/graph-view/graph-view.component';
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component';
|
||||
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
|
||||
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
|
||||
import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component';
|
||||
import { BookmarksService } from './core/bookmarks/bookmarks.service';
|
||||
import { SearchInputWithAssistantComponent } from './components/search-input-with-assistant/search-input-with-assistant.component';
|
||||
import { SearchHistoryService } from './core/search/search-history.service';
|
||||
import { GraphIndexService } from './core/graph/graph-index.service';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@@ -41,6 +45,8 @@ interface TocEntry {
|
||||
RawViewOverlayComponent,
|
||||
BookmarksPanelComponent,
|
||||
AddBookmarkModalComponent,
|
||||
GraphInlineSettingsComponent,
|
||||
SearchInputWithAssistantComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@@ -53,11 +59,14 @@ export class AppComponent implements OnDestroy {
|
||||
private downloadService = inject(DownloadService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private readonly searchHistoryService = inject(SearchHistoryService);
|
||||
private readonly graphIndexService = inject(GraphIndexService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// --- State Signals ---
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
isOutlineOpen = signal<boolean>(true);
|
||||
outlineTab = signal<'outline' | 'settings'>('outline');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
|
||||
selectedNoteId = signal<string>('');
|
||||
sidebarSearchTerm = signal<string>('');
|
||||
@@ -245,6 +254,14 @@ export class AppComponent implements OnDestroy {
|
||||
this.isOutlineOpen.set(false);
|
||||
}
|
||||
|
||||
// Initialize outline tab from localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = window.localStorage.getItem('graphPaneTab');
|
||||
if (savedTab === 'settings' || savedTab === 'outline') {
|
||||
this.outlineTab.set(savedTab as 'outline' | 'settings');
|
||||
}
|
||||
}
|
||||
|
||||
effect(() => {
|
||||
const isDesktop = this.isDesktopView();
|
||||
if (isDesktop && !this.wasDesktop) {
|
||||
@@ -270,6 +287,20 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to rebuild graph index when notes change
|
||||
effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.graphIndexService.rebuildIndex(notes);
|
||||
});
|
||||
|
||||
// Persist outline tab
|
||||
effect(() => {
|
||||
const tab = this.outlineTab();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('graphPaneTab', tab);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.selectedNote()) {
|
||||
this.isRawViewOpen.set(false);
|
||||
@@ -354,6 +385,10 @@ export class AppComponent implements OnDestroy {
|
||||
this.isOutlineOpen.set(state);
|
||||
}
|
||||
|
||||
setOutlineTab(tab: 'outline' | 'settings'): void {
|
||||
this.outlineTab.set(tab);
|
||||
}
|
||||
|
||||
isDesktop(): boolean {
|
||||
return this.isDesktopView();
|
||||
}
|
||||
@@ -522,6 +557,16 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onSearchSubmit(query: string): void {
|
||||
if (query && query.trim()) {
|
||||
// Add to history
|
||||
this.searchHistoryService.add('vault-search', query);
|
||||
// Update search term and switch to search view
|
||||
this.sidebarSearchTerm.set(query);
|
||||
this.activeView.set('search');
|
||||
}
|
||||
}
|
||||
|
||||
toggleRawView(): void {
|
||||
if (!this.selectedNote()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { GraphConfig, colorToCss } from './graph-settings.types';
|
||||
import { GraphDisplayOptions } from '../../components/graph-view/graph-view.component';
|
||||
import { GraphData, GraphNode } from '../../types';
|
||||
import { parseSearchQuery, queryToPredicate, SearchContext, SectionContent, TaskInfo } from '../../core/search/search-parser';
|
||||
|
||||
/**
|
||||
* Adapter to convert GraphConfig to runtime graph options
|
||||
* Applies filters, groups, display settings, and forces to the graph engine
|
||||
*/
|
||||
export class GraphRuntimeAdapter {
|
||||
|
||||
/**
|
||||
* Convert GraphConfig to GraphDisplayOptions for the graph-view component
|
||||
*/
|
||||
static configToDisplayOptions(config: GraphConfig): GraphDisplayOptions {
|
||||
return {
|
||||
showArrows: config.showArrow,
|
||||
textFadeThreshold: this.convertTextFadeMultiplier(config.textFadeMultiplier),
|
||||
nodeSize: this.convertNodeSize(config.nodeSizeMultiplier),
|
||||
linkThickness: this.convertLinkThickness(config.lineSizeMultiplier),
|
||||
chargeStrength: this.convertRepelStrength(config.repelStrength),
|
||||
linkDistance: config.linkDistance,
|
||||
centerStrength: config.centerStrength
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to graph data
|
||||
* Returns filtered nodes and edges based on config
|
||||
*/
|
||||
static applyFilters(
|
||||
graphData: GraphData,
|
||||
config: GraphConfig,
|
||||
allNotes: any[]
|
||||
): GraphData {
|
||||
let filteredNodes = [...graphData.nodes];
|
||||
let filteredEdges = [...graphData.edges];
|
||||
|
||||
// Apply search filter using Obsidian-compatible parser
|
||||
if (config.search && config.search.trim()) {
|
||||
const parsed = parseSearchQuery(config.search);
|
||||
const predicate = queryToPredicate(parsed);
|
||||
|
||||
const matchingNodeIds = new Set<string>();
|
||||
|
||||
for (const node of filteredNodes) {
|
||||
const note = allNotes.find(n => n.id === node.id);
|
||||
if (note) {
|
||||
const context = this.buildSearchContext(note);
|
||||
|
||||
if (predicate(context)) {
|
||||
matchingNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredNodes = filteredNodes.filter(node => matchingNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => matchingNodeIds.has(edge.source) && matchingNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter tags (if disabled, remove tag nodes)
|
||||
if (!config.showTags) {
|
||||
const tagNodeIds = new Set(
|
||||
filteredNodes.filter(node => node.label.startsWith('#')).map(n => n.id)
|
||||
);
|
||||
filteredNodes = filteredNodes.filter(node => !tagNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => !tagNodeIds.has(edge.source) && !tagNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter attachments (if disabled, remove attachment nodes)
|
||||
if (!config.showAttachments) {
|
||||
const attachmentNodeIds = new Set(
|
||||
filteredNodes.filter(node =>
|
||||
/\.(pdf|png|jpg|jpeg|gif|svg|webp|mp4|mp3|wav|doc|docx|xls|xlsx)$/i.test(node.label)
|
||||
).map(n => n.id)
|
||||
);
|
||||
filteredNodes = filteredNodes.filter(node => !attachmentNodeIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => !attachmentNodeIds.has(edge.source) && !attachmentNodeIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter unresolved (hideUnresolved = true means show only existing files)
|
||||
if (config.hideUnresolved) {
|
||||
const existingNoteIds = new Set(allNotes.map(note => note.id));
|
||||
filteredNodes = filteredNodes.filter(node => existingNoteIds.has(node.id));
|
||||
filteredEdges = filteredEdges.filter(
|
||||
edge => existingNoteIds.has(edge.source) && existingNoteIds.has(edge.target)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter orphans (if disabled, remove nodes with no connections)
|
||||
if (!config.showOrphans) {
|
||||
const connectedNodeIds = new Set<string>();
|
||||
filteredEdges.forEach(edge => {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
});
|
||||
filteredNodes = filteredNodes.filter(node => connectedNodeIds.has(node.id));
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: filteredNodes,
|
||||
edges: filteredEdges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply color groups to nodes
|
||||
* Returns a map of node ID to color
|
||||
*/
|
||||
static applyColorGroups(
|
||||
nodes: GraphNode[],
|
||||
config: GraphConfig,
|
||||
allNotes: any[]
|
||||
): Map<string, string> {
|
||||
const nodeColors = new Map<string, string>();
|
||||
|
||||
if (!config.colorGroups || config.colorGroups.length === 0) {
|
||||
return nodeColors;
|
||||
}
|
||||
|
||||
// Process each color group in order
|
||||
for (const group of config.colorGroups) {
|
||||
const matchingNodes = this.queryNodes(nodes, group.query, allNotes);
|
||||
const color = colorToCss(group.color);
|
||||
|
||||
for (const node of matchingNodes) {
|
||||
// First match wins (only set if not already colored)
|
||||
if (!nodeColors.has(node.id)) {
|
||||
nodeColors.set(node.id, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodeColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query nodes based on Obsidian-style query string
|
||||
* Uses the search parser for consistent behavior
|
||||
*/
|
||||
private static queryNodes(
|
||||
nodes: GraphNode[],
|
||||
query: string,
|
||||
allNotes: any[]
|
||||
): GraphNode[] {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = parseSearchQuery(query);
|
||||
const predicate = queryToPredicate(parsed);
|
||||
const matchingNodes: GraphNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const note = allNotes.find(n => n.id === node.id);
|
||||
if (note) {
|
||||
const context = this.buildSearchContext(note);
|
||||
|
||||
if (predicate(context)) {
|
||||
matchingNodes.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchingNodes;
|
||||
}
|
||||
|
||||
private static buildSearchContext(note: any): SearchContext {
|
||||
const content: string = note?.content || '';
|
||||
const lines = content.split('\n');
|
||||
|
||||
return {
|
||||
filePath: note?.filePath || note?.path || '',
|
||||
fileName: note?.name || '',
|
||||
fileNameWithExt: note?.name || '',
|
||||
content,
|
||||
tags: Array.isArray(note?.tags) ? note.tags : [],
|
||||
properties: note?.frontmatter || {},
|
||||
lines,
|
||||
blocks: this.extractBlocks(lines),
|
||||
sections: this.normalizeSections(note?.headings, content),
|
||||
tasks: this.extractTasks(content, note?.tasks)
|
||||
};
|
||||
}
|
||||
|
||||
private static extractBlocks(lines: string[]): string[] {
|
||||
const blocks: string[] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
if (current.length) {
|
||||
blocks.push(current.join('\n').trim());
|
||||
current = [];
|
||||
}
|
||||
} else {
|
||||
current.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length) {
|
||||
blocks.push(current.join('\n').trim());
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static normalizeSections(headings: any, content: string): SectionContent[] {
|
||||
if (Array.isArray(headings) && headings.length > 0) {
|
||||
return headings.map((item: any) => ({
|
||||
heading: item?.heading ?? item?.title ?? '',
|
||||
content: item?.content ?? '',
|
||||
level: typeof item?.level === 'number' ? item.level : (typeof item?.depth === 'number' ? item.depth : 1)
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: treat entire document as single section
|
||||
return [{ heading: '', content, level: 1 }];
|
||||
}
|
||||
|
||||
private static extractTasks(content: string, existingTasks: any): TaskInfo[] {
|
||||
const tasks: TaskInfo[] = [];
|
||||
|
||||
if (Array.isArray(existingTasks) && existingTasks.length > 0) {
|
||||
for (const task of existingTasks) {
|
||||
const text = task?.text ?? task?.description ?? '';
|
||||
if (!text) continue;
|
||||
tasks.push({
|
||||
text,
|
||||
completed: Boolean(task?.completed ?? task?.done ?? false),
|
||||
line: typeof task?.line === 'number' ? task.line : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length === 0 && content) {
|
||||
const lines = content.split('\n');
|
||||
const taskRegex = /^[\s>*-]*\[( |x|X)\]\s+(.*)$/;
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(taskRegex);
|
||||
if (match) {
|
||||
tasks.push({
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
line: index + 1
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert textFadeMultiplier (-3 to 3) to threshold percentage (0 to 100)
|
||||
* Obsidian's logic: negative values fade text earlier, positive values fade later
|
||||
*/
|
||||
private static convertTextFadeMultiplier(multiplier: number): number {
|
||||
// Map -3..3 to 0..100 with 0 mapping to 50
|
||||
// -3 -> 0 (fade very early), 0 -> 50 (default), 3 -> 100 (fade very late)
|
||||
return Math.round(((multiplier + 3) / 6) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert nodeSizeMultiplier (0.25 to 3) to absolute node size
|
||||
* Base size: 5 pixels
|
||||
*/
|
||||
private static convertNodeSize(multiplier: number): number {
|
||||
const baseSize = 5;
|
||||
return baseSize * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lineSizeMultiplier (0.25 to 3) to absolute link thickness
|
||||
* Base thickness: 1 pixel
|
||||
*/
|
||||
private static convertLinkThickness(multiplier: number): number {
|
||||
const baseThickness = 1;
|
||||
return baseThickness * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert repelStrength (0 to 20) to d3 charge strength (negative)
|
||||
* Obsidian's repel strength maps to negative charge force
|
||||
*/
|
||||
private static convertRepelStrength(repelStrength: number): number {
|
||||
// Map 0..20 to 0..-200 (stronger repulsion = more negative)
|
||||
return -repelStrength * 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Subject, debounceTime, catchError, of, tap } from 'rxjs';
|
||||
import { GraphConfig, DEFAULT_GRAPH_CONFIG, validateGraphConfig } from './graph-settings.types';
|
||||
|
||||
/** Response from graph config API */
|
||||
interface GraphConfigResponse {
|
||||
config: GraphConfig;
|
||||
rev: string;
|
||||
}
|
||||
|
||||
/** Unsubscribe function type */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* Service for managing graph settings stored in .obsidian/graph.json
|
||||
* Handles loading, saving, watching for external changes, and debouncing writes
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphSettingsService {
|
||||
private configSubject = new BehaviorSubject<GraphConfig>(DEFAULT_GRAPH_CONFIG);
|
||||
private saveQueue = new Subject<Partial<GraphConfig>>();
|
||||
private currentRev: string | null = null;
|
||||
private pollingInterval: any = null;
|
||||
|
||||
/** Current graph configuration as signal */
|
||||
config = signal<GraphConfig>(DEFAULT_GRAPH_CONFIG);
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
// Setup debounced save mechanism
|
||||
this.saveQueue.pipe(
|
||||
debounceTime(250)
|
||||
).subscribe(patch => {
|
||||
this.performSave(patch);
|
||||
});
|
||||
|
||||
// Load initial config
|
||||
this.load();
|
||||
|
||||
// Start polling for external changes (every 2 seconds)
|
||||
this.startWatching();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from server
|
||||
* Falls back to defaults if file doesn't exist or is invalid
|
||||
*/
|
||||
async load(): Promise<GraphConfig> {
|
||||
try {
|
||||
const response = await this.http.get<GraphConfigResponse>('/api/vault/graph')
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
console.warn('Failed to load graph config, using defaults:', err);
|
||||
return of({ config: DEFAULT_GRAPH_CONFIG, rev: 'default' });
|
||||
})
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (response) {
|
||||
const validated = this.validateAndMerge(response.config);
|
||||
this.currentRev = response.rev;
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
return validated;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading graph config:', error);
|
||||
}
|
||||
|
||||
return DEFAULT_GRAPH_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a partial configuration update for saving
|
||||
* Actual save is debounced to avoid excessive writes
|
||||
*/
|
||||
save(patch: Partial<GraphConfig>): void {
|
||||
// Update local state immediately for reactive UI
|
||||
const updated = { ...this.config(), ...patch };
|
||||
const validated = this.validateAndMerge(updated);
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
|
||||
// Queue for debounced save
|
||||
this.saveQueue.next(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual save operation to the server
|
||||
*/
|
||||
private async performSave(patch: Partial<GraphConfig>): Promise<void> {
|
||||
try {
|
||||
const current = this.config();
|
||||
const updated = { ...current, ...patch };
|
||||
const validated = this.validateAndMerge(updated);
|
||||
|
||||
const headers: any = {};
|
||||
if (this.currentRev) {
|
||||
headers['If-Match'] = this.currentRev;
|
||||
}
|
||||
|
||||
const response = await this.http.put<{ rev: string }>(
|
||||
'/api/vault/graph',
|
||||
validated,
|
||||
{ headers }
|
||||
).pipe(
|
||||
tap(result => {
|
||||
this.currentRev = result.rev;
|
||||
}),
|
||||
catchError(err => {
|
||||
if (err.status === 409) {
|
||||
console.warn('Graph config conflict detected, reloading...');
|
||||
this.load();
|
||||
} else {
|
||||
console.error('Failed to save graph config:', err);
|
||||
}
|
||||
return of(null);
|
||||
})
|
||||
).toPromise();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving graph config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for external changes to the configuration file
|
||||
*/
|
||||
watch(callback: (config: GraphConfig) => void): Unsubscribe {
|
||||
const subscription = this.configSubject.subscribe(callback);
|
||||
return () => subscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for external changes
|
||||
*/
|
||||
private startWatching(): void {
|
||||
if (this.pollingInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.checkForExternalChanges();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for external changes
|
||||
*/
|
||||
private stopWatching(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the config file was modified externally
|
||||
*/
|
||||
private async checkForExternalChanges(): Promise<void> {
|
||||
try {
|
||||
const response = await this.http.get<GraphConfigResponse>('/api/vault/graph')
|
||||
.pipe(
|
||||
catchError(() => of(null))
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
if (response && response.rev !== this.currentRev) {
|
||||
console.log('External graph config change detected, reloading...');
|
||||
const validated = this.validateAndMerge(response.config);
|
||||
this.currentRev = response.rev;
|
||||
this.config.set(validated);
|
||||
this.configSubject.next(validated);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default configuration
|
||||
*/
|
||||
resetToDefaults(): void {
|
||||
this.save(DEFAULT_GRAPH_CONFIG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a specific section to defaults
|
||||
*/
|
||||
resetSection(section: 'filters' | 'groups' | 'display' | 'forces'): void {
|
||||
const defaults = DEFAULT_GRAPH_CONFIG;
|
||||
|
||||
switch (section) {
|
||||
case 'filters':
|
||||
this.save({
|
||||
search: defaults.search,
|
||||
showTags: defaults.showTags,
|
||||
showAttachments: defaults.showAttachments,
|
||||
hideUnresolved: defaults.hideUnresolved,
|
||||
showOrphans: defaults.showOrphans
|
||||
});
|
||||
break;
|
||||
|
||||
case 'groups':
|
||||
this.save({
|
||||
colorGroups: defaults.colorGroups
|
||||
});
|
||||
break;
|
||||
|
||||
case 'display':
|
||||
this.save({
|
||||
showArrow: defaults.showArrow,
|
||||
textFadeMultiplier: defaults.textFadeMultiplier,
|
||||
nodeSizeMultiplier: defaults.nodeSizeMultiplier,
|
||||
lineSizeMultiplier: defaults.lineSizeMultiplier
|
||||
});
|
||||
break;
|
||||
|
||||
case 'forces':
|
||||
this.save({
|
||||
centerStrength: defaults.centerStrength,
|
||||
repelStrength: defaults.repelStrength,
|
||||
linkStrength: defaults.linkStrength,
|
||||
linkDistance: defaults.linkDistance
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a collapse state
|
||||
*/
|
||||
toggleCollapse(section: 'filter' | 'color-groups' | 'display' | 'forces'): void {
|
||||
const key = `collapse-${section}` as keyof GraphConfig;
|
||||
const current = this.config()[key] as boolean;
|
||||
this.save({ [key]: !current } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and merge configuration with defaults
|
||||
* Ensures all required fields exist and values are within bounds
|
||||
*/
|
||||
private validateAndMerge(config: Partial<GraphConfig>): GraphConfig {
|
||||
const merged = {
|
||||
...DEFAULT_GRAPH_CONFIG,
|
||||
...config
|
||||
};
|
||||
|
||||
// Validate numeric bounds
|
||||
const validated = validateGraphConfig(merged);
|
||||
|
||||
// Ensure colorGroups is an array
|
||||
if (!Array.isArray(validated.colorGroups)) {
|
||||
validated.colorGroups = [];
|
||||
}
|
||||
|
||||
return merged as GraphConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.stopWatching();
|
||||
this.configSubject.complete();
|
||||
this.saveQueue.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Type definitions for Obsidian graph.json configuration
|
||||
* These types match exactly the structure used in Obsidian's .obsidian/graph.json
|
||||
*/
|
||||
|
||||
/** RGB color stored as integer (0-16777215) */
|
||||
export type RgbInt = number;
|
||||
|
||||
/** Color object matching Obsidian's format */
|
||||
export interface GraphColor {
|
||||
a: number; // Alpha channel (0-1)
|
||||
rgb: RgbInt; // RGB color as integer
|
||||
}
|
||||
|
||||
/** Color group for node coloring based on queries */
|
||||
export interface GraphColorGroup {
|
||||
query: string; // Query string (e.g., "Tag:#markdown", "tag:#note", "file:test")
|
||||
color: GraphColor; // Color to apply
|
||||
}
|
||||
|
||||
/** Complete graph configuration matching Obsidian's graph.json */
|
||||
export interface GraphConfig {
|
||||
// Filters section
|
||||
'collapse-filter': boolean;
|
||||
search: string;
|
||||
showTags: boolean;
|
||||
showAttachments: boolean;
|
||||
hideUnresolved: boolean; // Note: inverted from "Existing files only" toggle
|
||||
showOrphans: boolean;
|
||||
|
||||
// Groups section
|
||||
'collapse-color-groups': boolean;
|
||||
colorGroups: GraphColorGroup[];
|
||||
|
||||
// Display section
|
||||
'collapse-display': boolean;
|
||||
showArrow: boolean;
|
||||
textFadeMultiplier: number; // Range: -3 to 3, step 0.1
|
||||
nodeSizeMultiplier: number; // Range: 0.25 to 3, step 0.05
|
||||
lineSizeMultiplier: number; // Range: 0.25 to 3, step 0.05
|
||||
|
||||
// Forces section
|
||||
'collapse-forces': boolean;
|
||||
centerStrength: number; // Range: 0 to 2, step 0.01
|
||||
repelStrength: number; // Range: 0 to 20, step 0.5
|
||||
linkStrength: number; // Range: 0 to 2, step 0.01
|
||||
linkDistance: number; // Range: 20 to 300, step 1
|
||||
|
||||
// Preserved but not exposed in UI
|
||||
scale?: number;
|
||||
close?: boolean;
|
||||
}
|
||||
|
||||
/** Default configuration values matching Obsidian defaults */
|
||||
export const DEFAULT_GRAPH_CONFIG: GraphConfig = {
|
||||
'collapse-filter': false,
|
||||
search: '',
|
||||
showTags: false,
|
||||
showAttachments: false,
|
||||
hideUnresolved: false,
|
||||
showOrphans: true,
|
||||
|
||||
'collapse-color-groups': false,
|
||||
colorGroups: [],
|
||||
|
||||
'collapse-display': false,
|
||||
showArrow: false,
|
||||
textFadeMultiplier: 0,
|
||||
nodeSizeMultiplier: 1,
|
||||
lineSizeMultiplier: 1,
|
||||
|
||||
'collapse-forces': false,
|
||||
centerStrength: 0.5,
|
||||
repelStrength: 10,
|
||||
linkStrength: 1,
|
||||
linkDistance: 250,
|
||||
|
||||
scale: 1,
|
||||
close: false
|
||||
};
|
||||
|
||||
/** Validation bounds for numeric values */
|
||||
export const GRAPH_CONFIG_BOUNDS = {
|
||||
textFadeMultiplier: { min: -3, max: 3, step: 0.1 },
|
||||
nodeSizeMultiplier: { min: 0.25, max: 3, step: 0.05 },
|
||||
lineSizeMultiplier: { min: 0.25, max: 3, step: 0.05 },
|
||||
centerStrength: { min: 0, max: 2, step: 0.01 },
|
||||
repelStrength: { min: 0, max: 20, step: 0.5 },
|
||||
linkStrength: { min: 0, max: 2, step: 0.01 },
|
||||
linkDistance: { min: 20, max: 300, step: 1 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility functions for color conversion
|
||||
*/
|
||||
|
||||
/** Convert RGB integer to RGBA object */
|
||||
export function intToRgba(rgb: RgbInt, alpha: number = 1): { r: number; g: number; b: number; a: number } {
|
||||
return {
|
||||
r: (rgb >> 16) & 255,
|
||||
g: (rgb >> 8) & 255,
|
||||
b: rgb & 255,
|
||||
a: alpha
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert RGBA values to RGB integer */
|
||||
export function rgbaToInt(r: number, g: number, b: number): RgbInt {
|
||||
return ((r & 255) << 16) | ((g & 255) << 8) | (b & 255);
|
||||
}
|
||||
|
||||
/** Convert hex color string to RGB integer */
|
||||
export function hexToInt(hex: string): RgbInt {
|
||||
const cleaned = hex.replace('#', '');
|
||||
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||
return rgbaToInt(r, g, b);
|
||||
}
|
||||
|
||||
/** Convert RGB integer to hex string */
|
||||
export function intToHex(rgb: RgbInt): string {
|
||||
const r = (rgb >> 16) & 255;
|
||||
const g = (rgb >> 8) & 255;
|
||||
const b = rgb & 255;
|
||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert GraphColor to CSS rgba string */
|
||||
export function colorToCss(color: GraphColor): string {
|
||||
const { r, g, b } = intToRgba(color.rgb, color.a);
|
||||
return `rgba(${r}, ${g}, ${b}, ${color.a})`;
|
||||
}
|
||||
|
||||
/** Create GraphColor from hex and alpha */
|
||||
export function createGraphColor(hex: string, alpha: number = 1): GraphColor {
|
||||
return {
|
||||
a: alpha,
|
||||
rgb: hexToInt(hex)
|
||||
};
|
||||
}
|
||||
|
||||
/** Clamp numeric value to bounds */
|
||||
export function clampValue(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/** Validate and clamp graph config numeric values */
|
||||
export function validateGraphConfig(config: Partial<GraphConfig>): Partial<GraphConfig> {
|
||||
const validated = { ...config };
|
||||
|
||||
if (typeof validated.textFadeMultiplier === 'number') {
|
||||
validated.textFadeMultiplier = clampValue(
|
||||
validated.textFadeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.textFadeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.textFadeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.nodeSizeMultiplier === 'number') {
|
||||
validated.nodeSizeMultiplier = clampValue(
|
||||
validated.nodeSizeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.nodeSizeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.nodeSizeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.lineSizeMultiplier === 'number') {
|
||||
validated.lineSizeMultiplier = clampValue(
|
||||
validated.lineSizeMultiplier,
|
||||
GRAPH_CONFIG_BOUNDS.lineSizeMultiplier.min,
|
||||
GRAPH_CONFIG_BOUNDS.lineSizeMultiplier.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.centerStrength === 'number') {
|
||||
validated.centerStrength = clampValue(
|
||||
validated.centerStrength,
|
||||
GRAPH_CONFIG_BOUNDS.centerStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.centerStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.repelStrength === 'number') {
|
||||
validated.repelStrength = clampValue(
|
||||
validated.repelStrength,
|
||||
GRAPH_CONFIG_BOUNDS.repelStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.repelStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.linkStrength === 'number') {
|
||||
validated.linkStrength = clampValue(
|
||||
validated.linkStrength,
|
||||
GRAPH_CONFIG_BOUNDS.linkStrength.min,
|
||||
GRAPH_CONFIG_BOUNDS.linkStrength.max
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof validated.linkDistance === 'number') {
|
||||
validated.linkDistance = clampValue(
|
||||
validated.linkDistance,
|
||||
GRAPH_CONFIG_BOUNDS.linkDistance.min,
|
||||
GRAPH_CONFIG_BOUNDS.linkDistance.max
|
||||
);
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GraphSettingsService } from '../graph-settings.service';
|
||||
import { GraphFiltersSectionComponent } from './sections/filters-section.component';
|
||||
import { GraphGroupsSectionComponent } from './sections/groups-section.component';
|
||||
import { GraphDisplaySectionComponent } from './sections/display-section.component';
|
||||
import { GraphForcesSectionComponent } from './sections/forces-section.component';
|
||||
import { GraphConfig } from '../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-inline-settings',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphFiltersSectionComponent,
|
||||
GraphGroupsSectionComponent,
|
||||
GraphDisplaySectionComponent,
|
||||
GraphForcesSectionComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header row for inline panel (optional small actions) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-obs-l-text-main dark:text-obs-d-text-main">Graph settings</h3>
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetAll()"
|
||||
class="rounded-lg p-1.5 text-obs-l-text-muted hover:bg-obs-l-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main/60"
|
||||
title="Reset all settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Filters</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-filters-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-filters-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Groups -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Groups</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-groups-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-groups-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Display</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-display-section [config]="config()" (configChange)="onConfigChange($event)" (animateRequested)="animateRequested.emit()"></app-graph-display-section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Forces -->
|
||||
<section class="rounded-xl border border-obs-l-border/60 bg-obs-l-bg-main/70 dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/60">
|
||||
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Forces</div>
|
||||
<div class="px-3 pb-3">
|
||||
<app-graph-forces-section [config]="config()" (configChange)="onConfigChange($event)"></app-graph-forces-section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class GraphInlineSettingsComponent {
|
||||
animateRequested = output<void>();
|
||||
private settings = inject(GraphSettingsService);
|
||||
config = this.settings.config;
|
||||
|
||||
onConfigChange(patch: Partial<GraphConfig>) {
|
||||
this.settings.save(patch);
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
if (confirm('Reset all graph settings to defaults?')) {
|
||||
this.settings.resetToDefaults();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-display-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- Arrows toggle -->
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showArrow"
|
||||
(change)="onToggleChange('showArrow', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Arrows</span>
|
||||
</label>
|
||||
|
||||
<!-- Text fade threshold slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Text fade threshold</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().textFadeMultiplier, 1) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.textFadeMultiplier.min"
|
||||
[max]="bounds.textFadeMultiplier.max"
|
||||
[step]="bounds.textFadeMultiplier.step"
|
||||
[value]="config().textFadeMultiplier"
|
||||
(input)="onSliderChange('textFadeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.textFadeMultiplier.min }}</span>
|
||||
<span>{{ bounds.textFadeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node size slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Node size</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().nodeSizeMultiplier, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.nodeSizeMultiplier.min"
|
||||
[max]="bounds.nodeSizeMultiplier.max"
|
||||
[step]="bounds.nodeSizeMultiplier.step"
|
||||
[value]="config().nodeSizeMultiplier"
|
||||
(input)="onSliderChange('nodeSizeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.nodeSizeMultiplier.min }}</span>
|
||||
<span>{{ bounds.nodeSizeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link thickness slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link thickness</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().lineSizeMultiplier, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.lineSizeMultiplier.min"
|
||||
[max]="bounds.lineSizeMultiplier.max"
|
||||
[step]="bounds.lineSizeMultiplier.step"
|
||||
[value]="config().lineSizeMultiplier"
|
||||
(input)="onSliderChange('lineSizeMultiplier', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.lineSizeMultiplier.min }}</span>
|
||||
<span>{{ bounds.lineSizeMultiplier.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animate button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="animateRequested.emit()"
|
||||
class="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors shadow-sm">
|
||||
Animate
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphDisplaySectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
animateRequested = output<void>();
|
||||
|
||||
bounds = GRAPH_CONFIG_BOUNDS;
|
||||
|
||||
onToggleChange(key: keyof GraphConfig, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.configChange.emit({ [key]: checked } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
onSliderChange(key: keyof GraphConfig, event: Event): void {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
formatNumber(value: number, decimals: number): string {
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-filters-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- Search input -->
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="config().search"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search files..."
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- Toggle options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showTags"
|
||||
(change)="onToggleChange('showTags', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Tags</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showAttachments"
|
||||
(change)="onToggleChange('showAttachments', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Attachments</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().hideUnresolved"
|
||||
(change)="onToggleChange('hideUnresolved', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Existing files only</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="config().showOrphans"
|
||||
(change)="onToggleChange('showOrphans', $event)"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Orphans</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class GraphFiltersSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
onSearchChange(value: string): void {
|
||||
this.configChange.emit({ search: value });
|
||||
}
|
||||
|
||||
onToggleChange(key: keyof GraphConfig, event: Event): void {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.configChange.emit({ [key]: checked } as Partial<GraphConfig>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-forces-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- Center force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Center force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().centerStrength, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.centerStrength.min"
|
||||
[max]="bounds.centerStrength.max"
|
||||
[step]="bounds.centerStrength.step"
|
||||
[value]="config().centerStrength"
|
||||
(input)="onSliderChange('centerStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.centerStrength.min }}</span>
|
||||
<span>{{ bounds.centerStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repel force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Repel force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().repelStrength, 1) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.repelStrength.min"
|
||||
[max]="bounds.repelStrength.max"
|
||||
[step]="bounds.repelStrength.step"
|
||||
[value]="config().repelStrength"
|
||||
(input)="onSliderChange('repelStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.repelStrength.min }}</span>
|
||||
<span>{{ bounds.repelStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link force slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link force</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkStrength, 2) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.linkStrength.min"
|
||||
[max]="bounds.linkStrength.max"
|
||||
[step]="bounds.linkStrength.step"
|
||||
[value]="config().linkStrength"
|
||||
(input)="onSliderChange('linkStrength', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.linkStrength.min }}</span>
|
||||
<span>{{ bounds.linkStrength.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link distance slider -->
|
||||
<div>
|
||||
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<span>Link distance</span>
|
||||
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkDistance, 0) }}px</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[min]="bounds.linkDistance.min"
|
||||
[max]="bounds.linkDistance.max"
|
||||
[step]="bounds.linkDistance.step"
|
||||
[value]="config().linkDistance"
|
||||
(input)="onSliderChange('linkDistance', $event)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>{{ bounds.linkDistance.min }}</span>
|
||||
<span>{{ bounds.linkDistance.max }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphForcesSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
bounds = GRAPH_CONFIG_BOUNDS;
|
||||
|
||||
onSliderChange(key: keyof GraphConfig, event: Event): void {
|
||||
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||
this.configChange.emit({ [key]: value } as Partial<GraphConfig>);
|
||||
}
|
||||
|
||||
formatNumber(value: number, decimals: number): string {
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GraphConfig, GraphColorGroup, intToHex, createGraphColor } from '../../graph-settings.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-groups-section',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- Color groups list -->
|
||||
@if (config().colorGroups.length > 0) {
|
||||
<div class="space-y-2">
|
||||
@for (group of config().colorGroups; track $index) {
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<!-- Color picker -->
|
||||
<input
|
||||
type="color"
|
||||
[value]="getColorHex(group)"
|
||||
(change)="onColorChange($index, $event)"
|
||||
class="w-8 h-8 rounded cursor-pointer border-0"
|
||||
title="Change color">
|
||||
|
||||
<!-- Query input -->
|
||||
<input
|
||||
type="text"
|
||||
[value]="group.query"
|
||||
(input)="onQueryChange($index, $event)"
|
||||
placeholder="tag:#example"
|
||||
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
|
||||
<!-- Actions -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="onDuplicate($index)"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
title="Duplicate">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="onDelete($index)"
|
||||
class="p-1 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Delete">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- New group button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="onAddGroup()"
|
||||
class="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors shadow-sm">
|
||||
New group
|
||||
</button>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<div><strong>Examples:</strong></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">tag:#markdown</code></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">file:test</code></div>
|
||||
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">path:folder</code></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphGroupsSectionComponent {
|
||||
config = input.required<GraphConfig>();
|
||||
configChange = output<Partial<GraphConfig>>();
|
||||
|
||||
private readonly defaultColors = [
|
||||
'#E06C75', // red
|
||||
'#98C379', // green
|
||||
'#61AFEF', // blue
|
||||
'#C678DD', // purple
|
||||
'#E5C07B', // yellow
|
||||
'#56B6C2', // cyan
|
||||
'#D19A66', // orange
|
||||
];
|
||||
|
||||
getColorHex(group: GraphColorGroup): string {
|
||||
return intToHex(group.color.rgb);
|
||||
}
|
||||
|
||||
onColorChange(index: number, event: Event): void {
|
||||
const hex = (event.target as HTMLInputElement).value;
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups[index] = {
|
||||
...groups[index],
|
||||
color: createGraphColor(hex, groups[index].color.a)
|
||||
};
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onQueryChange(index: number, event: Event): void {
|
||||
const query = (event.target as HTMLInputElement).value;
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups[index] = {
|
||||
...groups[index],
|
||||
query
|
||||
};
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onAddGroup(): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
const colorIndex = groups.length % this.defaultColors.length;
|
||||
const newGroup: GraphColorGroup = {
|
||||
query: '',
|
||||
color: createGraphColor(this.defaultColors[colorIndex], 1)
|
||||
};
|
||||
groups.push(newGroup);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onDuplicate(index: number): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
const duplicate = { ...groups[index] };
|
||||
groups.splice(index + 1, 0, duplicate);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
|
||||
onDelete(index: number): void {
|
||||
const groups = [...this.config().colorGroups];
|
||||
groups.splice(index, 1);
|
||||
this.configChange.emit({ colorGroups: groups });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Component, ChangeDetectionStrategy, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-settings-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
(click)="clicked.emit()"
|
||||
(keydown.enter)="clicked.emit()"
|
||||
(keydown.space)="clicked.emit(); $event.preventDefault()"
|
||||
class="settings-button"
|
||||
aria-label="Graph settings"
|
||||
title="Settings">
|
||||
<!-- Gear icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-button {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
color: #1F2937;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.35), 0 8px 10px -6px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
color: #0F172A;
|
||||
transform: rotate(90deg);
|
||||
box-shadow: 0 18px 30px -8px rgba(15, 23, 42, 0.45), 0 10px 20px -10px rgba(30, 41, 59, 0.4);
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.settings-button:focus {
|
||||
outline: 2px solid #3B82F6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-button:active {
|
||||
transform: rotate(90deg) scale(0.95);
|
||||
}
|
||||
|
||||
:host-context(.dark) .settings-button {
|
||||
background-color: rgba(30, 41, 59, 0.92);
|
||||
color: #E2E8F0;
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
box-shadow: 0 12px 30px -8px rgba(8, 47, 73, 0.55), 0 10px 18px -10px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
:host-context(.dark) .settings-button:hover {
|
||||
background-color: rgba(51, 65, 85, 0.95);
|
||||
color: #F8FAFC;
|
||||
border-color: rgba(226, 232, 240, 0.65);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphSettingsButtonComponent {
|
||||
clicked = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, inject, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { GraphConfig } from '../graph-settings.types';
|
||||
import { GraphSettingsService } from '../graph-settings.service';
|
||||
import { GraphFiltersSectionComponent } from './sections/filters-section.component';
|
||||
import { GraphGroupsSectionComponent } from './sections/groups-section.component';
|
||||
import { GraphDisplaySectionComponent } from './sections/display-section.component';
|
||||
import { GraphForcesSectionComponent } from './sections/forces-section.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-settings-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphFiltersSectionComponent,
|
||||
GraphGroupsSectionComponent,
|
||||
GraphDisplaySectionComponent,
|
||||
GraphForcesSectionComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="settings-panel">
|
||||
@if (isOpen()) {
|
||||
<div class="backdrop" (click)="close.emit()"></div>
|
||||
|
||||
<div class="panel-content">
|
||||
<div class="panel-header">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Graph settings</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="onResetAll()"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Reset all settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="close.emit()"
|
||||
(keydown.escape)="close.emit()"
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Close settings"
|
||||
title="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('filter')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-filter']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-filter']) {
|
||||
<div class="section-content">
|
||||
<app-graph-filters-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-filters-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('color-groups')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
<span>Groups</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-color-groups']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-color-groups']) {
|
||||
<div class="section-content">
|
||||
<app-graph-groups-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-groups-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('display')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>Display</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-display']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-display']) {
|
||||
<div class="section-content">
|
||||
<app-graph-display-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)"
|
||||
(animateRequested)="animateRequested.emit()">
|
||||
</app-graph-display-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<button type="button" (click)="toggleSection('forces')" class="section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Forces</span>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform" [class.rotate-180]="!config()['collapse-forces']" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!config()['collapse-forces']) {
|
||||
<div class="section-content">
|
||||
<app-graph-forces-section
|
||||
[config]="config()"
|
||||
(configChange)="onConfigChange($event)">
|
||||
</app-graph-forces-section>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1990;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background-color: white;
|
||||
box-shadow: -4px 0 6px -1px rgba(0, 0, 0, 0.1), -2px 0 4px -1px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 2001;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
:host-context(.dark) .panel-content {
|
||||
background-color: #1F2937;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
:host-context(.dark) .panel-header {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #F9FAFB;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background-color: #F3F4F6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-header {
|
||||
background-color: #374151;
|
||||
color: #F3F4F6;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-header:hover {
|
||||
background-color: #4B5563;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:host-context(.dark) .section-content {
|
||||
background-color: #1F2937;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphSettingsPanelComponent {
|
||||
isOpen = input.required<boolean>();
|
||||
close = output<void>();
|
||||
animateRequested = output<void>();
|
||||
|
||||
private settingsService = inject(GraphSettingsService);
|
||||
|
||||
config = this.settingsService.config;
|
||||
|
||||
constructor() {
|
||||
// Listen to Escape key globally when panel is open
|
||||
effect(() => {
|
||||
if (this.isOpen()) {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.close.emit();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
||||
onConfigChange(patch: Partial<GraphConfig>): void {
|
||||
this.settingsService.save(patch);
|
||||
}
|
||||
|
||||
toggleSection(section: 'filter' | 'color-groups' | 'display' | 'forces'): void {
|
||||
this.settingsService.toggleCollapse(section);
|
||||
}
|
||||
|
||||
onResetAll(): void {
|
||||
if (confirm('Reset all graph settings to defaults?')) {
|
||||
this.settingsService.resetToDefaults();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Component, ChangeDetectionStrategy, output, signal } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, output, signal, effect, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchInputWithAssistantComponent } from '../search-input-with-assistant/search-input-with-assistant.component';
|
||||
import { GraphSettingsService } from '../../app/graph/graph-settings.service';
|
||||
import { GraphConfig, GraphColorGroup, createGraphColor, intToHex } from '../../app/graph/graph-settings.types';
|
||||
|
||||
export interface GraphFilters {
|
||||
showTags: boolean;
|
||||
@@ -32,7 +35,7 @@ export interface GraphOptions {
|
||||
@Component({
|
||||
selector: 'app-graph-options-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, SearchInputWithAssistantComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="graph-options-panel bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
|
||||
@@ -86,12 +89,15 @@ export interface GraphOptions {
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="filters().searchQuery"
|
||||
(ngModelChange)="emitOptionsChange()"
|
||||
placeholder="🔍 Search files…"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<app-search-input-with-assistant
|
||||
[value]="searchQuery()"
|
||||
(valueChange)="onSearchChange($event)"
|
||||
(submit)="onSearchSubmit($event)"
|
||||
[placeholder]="'Search files…'"
|
||||
[context]="'graph'"
|
||||
[showSearchIcon]="true"
|
||||
[inputClass]="'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -104,8 +110,36 @@ export interface GraphOptions {
|
||||
Groups
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2 mb-3">
|
||||
@for (group of colorGroups(); track $index) {
|
||||
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div
|
||||
[style.background-color]="getGroupColorPreview(group)"
|
||||
class="w-6 h-6 rounded-full border-2 border-white dark:border-gray-800 shadow-sm flex-shrink-0">
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
[value]="group.query"
|
||||
(input)="updateGroupQuery($index, $any($event.target).value)"
|
||||
placeholder="Query (e.g., tag:#code)"
|
||||
class="flex-1 px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded"
|
||||
/>
|
||||
<button
|
||||
(click)="removeGroup($index)"
|
||||
class="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Remove group"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="addNewGroup()"
|
||||
class="w-full px-3 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors">
|
||||
New group
|
||||
</button>
|
||||
@@ -291,9 +325,9 @@ export interface GraphOptions {
|
||||
})
|
||||
export class GraphOptionsPanelComponent {
|
||||
filters = signal<GraphFilters>({
|
||||
showTags: true,
|
||||
showAttachments: true,
|
||||
existingFilesOnly: true,
|
||||
showTags: false,
|
||||
showAttachments: false,
|
||||
existingFilesOnly: false,
|
||||
showOrphans: true,
|
||||
searchQuery: ''
|
||||
});
|
||||
@@ -311,16 +345,89 @@ export class GraphOptionsPanelComponent {
|
||||
centerStrength: 0.05
|
||||
});
|
||||
|
||||
searchQuery = signal<string>('');
|
||||
colorGroups = signal<GraphColorGroup[]>([]);
|
||||
forcesExpanded = signal(false);
|
||||
|
||||
optionsChanged = output<GraphOptions>();
|
||||
animateRequested = output<void>();
|
||||
|
||||
constructor(private graphSettings: GraphSettingsService) {
|
||||
// Sync with graph settings service
|
||||
effect(() => {
|
||||
const config = this.graphSettings.config();
|
||||
|
||||
this.filters.set({
|
||||
showTags: config.showTags,
|
||||
showAttachments: config.showAttachments,
|
||||
existingFilesOnly: config.hideUnresolved,
|
||||
showOrphans: config.showOrphans,
|
||||
searchQuery: config.search
|
||||
});
|
||||
|
||||
this.searchQuery.set(config.search);
|
||||
this.colorGroups.set([...config.colorGroups]);
|
||||
|
||||
this.emitOptionsChange();
|
||||
});
|
||||
}
|
||||
|
||||
emitOptionsChange(): void {
|
||||
this.optionsChanged.emit({
|
||||
filters: this.filters(),
|
||||
display: this.display(),
|
||||
forces: this.forces()
|
||||
});
|
||||
|
||||
// Save to settings service
|
||||
this.graphSettings.save({
|
||||
showTags: this.filters().showTags,
|
||||
showAttachments: this.filters().showAttachments,
|
||||
hideUnresolved: this.filters().existingFilesOnly,
|
||||
showOrphans: this.filters().showOrphans,
|
||||
search: this.searchQuery()
|
||||
});
|
||||
}
|
||||
|
||||
onSearchChange(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
this.filters.update(f => ({ ...f, searchQuery: query }));
|
||||
this.graphSettings.save({ search: query });
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
onSearchSubmit(query: string): void {
|
||||
// Add to history and apply
|
||||
this.onSearchChange(query);
|
||||
}
|
||||
|
||||
addNewGroup(): void {
|
||||
const newGroup: GraphColorGroup = {
|
||||
query: 'file:test',
|
||||
color: createGraphColor('#E879F9', 1) // Purple default
|
||||
};
|
||||
|
||||
const updated = [...this.colorGroups(), newGroup];
|
||||
this.colorGroups.set(updated);
|
||||
this.graphSettings.save({ colorGroups: updated });
|
||||
}
|
||||
|
||||
removeGroup(index: number): void {
|
||||
const updated = this.colorGroups().filter((_, i) => i !== index);
|
||||
this.colorGroups.set(updated);
|
||||
this.graphSettings.save({ colorGroups: updated });
|
||||
}
|
||||
|
||||
updateGroupQuery(index: number, query: string): void {
|
||||
const updated = this.colorGroups().map((g, i) =>
|
||||
i === index ? { ...g, query } : g
|
||||
);
|
||||
this.colorGroups.set(updated);
|
||||
this.graphSettings.save({ colorGroups: updated });
|
||||
}
|
||||
|
||||
getGroupColorPreview(group: GraphColorGroup): string {
|
||||
const hex = intToHex(group.color.rgb);
|
||||
return `${hex}${Math.round(group.color.a * 255).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,33 @@ import { GraphViewComponent, GraphDisplayOptions } from '../graph-view/graph-vie
|
||||
import { GraphOptionsPanelComponent, GraphOptions } from '../graph-options-panel/graph-options-panel.component';
|
||||
import { GraphData } from '../../types';
|
||||
import { NoteIndexService } from '../../services/note-index.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { GraphSettingsService } from '../../app/graph/graph-settings.service';
|
||||
import { GraphSettingsPanelComponent } from '../../app/graph/ui/settings-panel.component';
|
||||
import { GraphRuntimeAdapter } from '../../app/graph/graph-runtime-adapter';
|
||||
|
||||
@Component({
|
||||
selector: 'app-graph-view-container',
|
||||
standalone: true,
|
||||
imports: [CommonModule, GraphViewComponent, GraphOptionsPanelComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GraphViewComponent,
|
||||
GraphOptionsPanelComponent,
|
||||
GraphSettingsPanelComponent
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="graph-view-container flex h-full w-full">
|
||||
<div class="graph-view-container flex h-full w-full relative">
|
||||
<!-- Graph View -->
|
||||
<div class="flex-1 relative">
|
||||
<app-graph-view
|
||||
[graphData]="graphData()"
|
||||
[graphData]="filteredGraphData()"
|
||||
[displayOptions]="displayOptions()"
|
||||
(nodeSelected)="onNodeSelected($event)">
|
||||
(nodeSelected)="onNodeSelected($event)"
|
||||
(settingsRequested)="openSettingsPanel()">
|
||||
</app-graph-view>
|
||||
|
||||
<!-- Toggle Options Button (Mobile) -->
|
||||
|
||||
<!-- Toggle Options Button (Mobile - Old Panel) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleOptionsPanel()"
|
||||
@@ -35,7 +45,7 @@ import { NoteIndexService } from '../../services/note-index.service';
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Options Panel -->
|
||||
<!-- Old Options Panel (kept for backward compatibility) -->
|
||||
<div
|
||||
class="options-panel-wrapper"
|
||||
[class.open]="optionsPanelOpen()"
|
||||
@@ -46,6 +56,13 @@ import { NoteIndexService } from '../../services/note-index.service';
|
||||
</app-graph-options-panel>
|
||||
</div>
|
||||
|
||||
<!-- New Settings Panel -->
|
||||
<app-graph-settings-panel
|
||||
[isOpen]="settingsPanelOpen()"
|
||||
(close)="closeSettingsPanel()"
|
||||
(animateRequested)="onAnimateRequested()">
|
||||
</app-graph-settings-panel>
|
||||
|
||||
<!-- Overlay for mobile -->
|
||||
@if (optionsPanelOpen() && isMobile()) {
|
||||
<div
|
||||
@@ -94,10 +111,14 @@ export class GraphViewContainerComponent {
|
||||
nodeSelected = output<string>();
|
||||
|
||||
private noteIndexService = inject(NoteIndexService);
|
||||
private vaultService = inject(VaultService);
|
||||
private graphSettingsService = inject(GraphSettingsService);
|
||||
|
||||
optionsPanelOpen = signal(false);
|
||||
settingsPanelOpen = signal(false);
|
||||
|
||||
graphData = computed<GraphData>(() => {
|
||||
// Base graph data from note index
|
||||
private baseGraphData = computed<GraphData>(() => {
|
||||
const centerNote = this.centerNoteId();
|
||||
if (!centerNote) {
|
||||
return this.noteIndexService.buildFullGraphData();
|
||||
@@ -105,46 +126,53 @@ export class GraphViewContainerComponent {
|
||||
return this.noteIndexService.buildGraphData(centerNote, 2);
|
||||
});
|
||||
|
||||
displayOptions = signal<GraphDisplayOptions>({
|
||||
showArrows: true,
|
||||
textFadeThreshold: 50,
|
||||
nodeSize: 5,
|
||||
linkThickness: 1,
|
||||
chargeStrength: -100,
|
||||
linkDistance: 100,
|
||||
centerStrength: 0.05,
|
||||
centerNodeId: undefined
|
||||
// Filtered graph data based on settings
|
||||
filteredGraphData = computed<GraphData>(() => {
|
||||
const baseData = this.baseGraphData();
|
||||
const config = this.graphSettingsService.config();
|
||||
const allNotes = this.vaultService.allNotes();
|
||||
|
||||
return GraphRuntimeAdapter.applyFilters(baseData, config, allNotes);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const centerNote = this.centerNoteId();
|
||||
if (centerNote) {
|
||||
this.displayOptions.update(opts => ({
|
||||
...opts,
|
||||
centerNodeId: centerNote
|
||||
}));
|
||||
}
|
||||
// Display options from graph config
|
||||
displayOptions = computed<GraphDisplayOptions>(() => {
|
||||
const config = this.graphSettingsService.config();
|
||||
const baseOptions = GraphRuntimeAdapter.configToDisplayOptions(config);
|
||||
const graphData = this.filteredGraphData();
|
||||
const allNotes = this.vaultService.allNotes();
|
||||
const nodeColors = GraphRuntimeAdapter.applyColorGroups(graphData.nodes, config, allNotes);
|
||||
|
||||
return {
|
||||
...baseOptions,
|
||||
centerNodeId: this.centerNoteId(),
|
||||
nodeColors
|
||||
};
|
||||
});
|
||||
|
||||
onOptionsChanged(options: GraphOptions): void {
|
||||
// Old options panel - update graph settings service
|
||||
this.graphSettingsService.save({
|
||||
showArrow: options.display.showArrows,
|
||||
textFadeMultiplier: this.convertTextFadeThreshold(options.display.textFadeThreshold),
|
||||
nodeSizeMultiplier: options.display.nodeSize / 5,
|
||||
lineSizeMultiplier: options.display.linkThickness,
|
||||
repelStrength: -options.forces.chargeStrength / 10,
|
||||
linkDistance: options.forces.linkDistance,
|
||||
centerStrength: options.forces.centerStrength
|
||||
});
|
||||
}
|
||||
|
||||
onOptionsChanged(options: GraphOptions): void {
|
||||
this.displayOptions.update(current => ({
|
||||
...current,
|
||||
showArrows: options.display.showArrows,
|
||||
textFadeThreshold: options.display.textFadeThreshold,
|
||||
nodeSize: options.display.nodeSize,
|
||||
linkThickness: options.display.linkThickness,
|
||||
chargeStrength: options.forces.chargeStrength,
|
||||
linkDistance: options.forces.linkDistance,
|
||||
centerStrength: options.forces.centerStrength
|
||||
}));
|
||||
onAnimateRequested(): void {
|
||||
// Force graph to re-render by triggering effect
|
||||
// Simply refresh the settings to trigger computed signals
|
||||
const current = this.graphSettingsService.config();
|
||||
this.graphSettingsService.save({ ...current });
|
||||
}
|
||||
|
||||
onAnimateRequested(): void {
|
||||
// Trigger animation restart
|
||||
// The graph component will handle this via its effect on displayOptions
|
||||
this.displayOptions.update(opts => ({ ...opts }));
|
||||
private convertTextFadeThreshold(threshold: number): number {
|
||||
// Convert 0-100 threshold back to -3 to 3 multiplier
|
||||
return ((threshold / 100) * 6) - 3;
|
||||
}
|
||||
|
||||
onNodeSelected(nodeId: string): void {
|
||||
@@ -155,6 +183,14 @@ export class GraphViewContainerComponent {
|
||||
this.optionsPanelOpen.update(open => !open);
|
||||
}
|
||||
|
||||
closeSettingsPanel(): void {
|
||||
this.settingsPanelOpen.set(false);
|
||||
}
|
||||
|
||||
openSettingsPanel(): void {
|
||||
this.settingsPanelOpen.set(true);
|
||||
}
|
||||
|
||||
isMobile(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 769;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface GraphDisplayOptions {
|
||||
linkDistance: number;
|
||||
centerStrength: number;
|
||||
centerNodeId?: string;
|
||||
nodeColors?: Map<string, string>; // Map of node ID to color
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -79,7 +80,8 @@ export interface GraphDisplayOptions {
|
||||
[attr.r]="displayOptions().nodeSize"
|
||||
[class.ring-4]="node.id === displayOptions().centerNodeId"
|
||||
[class.ring-blue-500]="node.id === displayOptions().centerNodeId"
|
||||
class="fill-blue-500 dark:fill-blue-400 transition-all group-hover:fill-blue-600 dark:group-hover:fill-blue-300"
|
||||
[attr.fill]="getNodeColor(node)"
|
||||
class="transition-all"
|
||||
[attr.opacity]="getNodeOpacity(node)">
|
||||
</circle>
|
||||
|
||||
@@ -101,6 +103,22 @@ export interface GraphDisplayOptions {
|
||||
<div><strong>Nodes:</strong> {{ simulatedNodes().length }}</div>
|
||||
<div><strong>Links:</strong> {{ edges().length }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="settingsRequested.emit()"
|
||||
(keydown.enter)="settingsRequested.emit()"
|
||||
(keydown.space)="settingsRequested.emit(); $event.preventDefault()"
|
||||
class="graph-settings-button"
|
||||
aria-label="Graph settings"
|
||||
title="Graph settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -117,6 +135,56 @@ export interface GraphDisplayOptions {
|
||||
svg.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-settings-button {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(255, 255, 255, 0.96);
|
||||
color: #1F2937;
|
||||
box-shadow: 0 12px 28px -8px rgba(15, 23, 42, 0.5), 0 8px 16px -10px rgba(30, 41, 59, 0.35);
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.graph-settings-button:hover {
|
||||
background-color: rgba(248, 250, 252, 1);
|
||||
color: #0F172A;
|
||||
transform: rotate(90deg);
|
||||
box-shadow: 0 18px 32px -10px rgba(15, 23, 42, 0.55), 0 12px 20px -12px rgba(30, 41, 59, 0.45);
|
||||
border-color: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
.graph-settings-button:focus {
|
||||
outline: 2px solid #3B82F6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.graph-settings-button:active {
|
||||
transform: rotate(90deg) scale(0.95);
|
||||
}
|
||||
|
||||
:host-context(.dark) .graph-settings-button {
|
||||
background-color: rgba(30, 41, 59, 0.92);
|
||||
color: #E2E8F0;
|
||||
border-color: rgba(148, 163, 184, 0.55);
|
||||
box-shadow: 0 14px 32px -10px rgba(8, 47, 73, 0.6), 0 12px 20px -12px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
:host-context(.dark) .graph-settings-button:hover {
|
||||
background-color: rgba(51, 65, 85, 0.95);
|
||||
color: #F8FAFC;
|
||||
border-color: rgba(226, 232, 240, 0.65);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GraphViewComponent implements OnDestroy {
|
||||
@@ -133,6 +201,7 @@ export class GraphViewComponent implements OnDestroy {
|
||||
|
||||
nodeSelected = output<string>();
|
||||
nodeHovered = output<string>();
|
||||
settingsRequested = output<void>();
|
||||
|
||||
svg = viewChild.required<ElementRef<SVGSVGElement>>('graphSvg');
|
||||
|
||||
@@ -355,5 +424,14 @@ export class GraphViewComponent implements OnDestroy {
|
||||
selectNode(nodeId: string): void {
|
||||
this.nodeSelected.emit(nodeId);
|
||||
}
|
||||
|
||||
getNodeColor(node: SimulatedNode): string {
|
||||
const nodeColors = this.displayOptions().nodeColors;
|
||||
if (nodeColors && nodeColors.has(node.id)) {
|
||||
return nodeColors.get(node.id)!;
|
||||
}
|
||||
// Default color
|
||||
return '#3B82F6'; // blue-500
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
AfterViewInit,
|
||||
OnInit,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
|
||||
import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
|
||||
/**
|
||||
* Main search bar component with Aa (case sensitivity) and .* (regex) buttons
|
||||
* Provides a unified search interface with assistant popover
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, SearchQueryAssistantComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="relative w-full">
|
||||
<div class="relative flex items-center gap-1">
|
||||
<!-- Search icon -->
|
||||
<svg
|
||||
*ngIf="showSearchIcon"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-muted dark:text-gray-400 z-10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
<!-- Input field -->
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[(ngModel)]="query"
|
||||
(input)="onInputChange()"
|
||||
(focus)="onFocus()"
|
||||
(keydown)="onKeyDown($event)"
|
||||
(keydown.enter)="onEnter()"
|
||||
(keydown.escape)="onEscape()"
|
||||
[class]="inputClass"
|
||||
[style.padding-left]="showSearchIcon ? '2.5rem' : '0.75rem'"
|
||||
[style.padding-right]="'7rem'"
|
||||
[attr.aria-label]="placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
<!-- Control buttons container -->
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<!-- Case sensitivity button (Aa) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleCaseSensitivity()"
|
||||
[class.bg-accent]="caseSensitive()"
|
||||
[class.text-white]="caseSensitive()"
|
||||
[class.hover:bg-bg-muted]="!caseSensitive()"
|
||||
[class.dark:hover:bg-gray-700]="!caseSensitive()"
|
||||
class="px-2 py-1 rounded text-xs font-semibold transition-colors border border-border dark:border-gray-600"
|
||||
[title]="caseSensitive() ? 'Case sensitive' : 'Case insensitive'"
|
||||
aria-label="Toggle case sensitivity"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
|
||||
<!-- Regex mode button (.*) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleRegexMode()"
|
||||
[class.bg-accent]="regexMode()"
|
||||
[class.text-white]="regexMode()"
|
||||
[class.hover:bg-bg-muted]="!regexMode()"
|
||||
[class.dark:hover:bg-gray-700]="!regexMode()"
|
||||
class="px-2 py-1 rounded text-xs font-mono transition-colors border border-border dark:border-gray-600"
|
||||
[title]="regexMode() ? 'Regex mode enabled' : 'Regex mode disabled'"
|
||||
aria-label="Toggle regex mode"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
*ngIf="query"
|
||||
type="button"
|
||||
(click)="clear()"
|
||||
class="p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search assistant popover -->
|
||||
<app-search-query-assistant
|
||||
#assistant
|
||||
[context]="context"
|
||||
[currentQuery]="query"
|
||||
[anchorElement]="anchorElement"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(querySubmit)="onQuerySubmit($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
@Input() placeholder: string = 'Search in vault...';
|
||||
@Input() context: string = 'vault';
|
||||
@Input() showSearchIcon: boolean = true;
|
||||
@Input() inputClass: string = 'w-full px-3 py-2 text-sm border border-border dark:border-gray-600 rounded-lg bg-bg-primary dark:bg-gray-800 text-text-main dark:text-gray-100 placeholder-text-muted dark:placeholder-gray-500 focus:ring-2 focus:ring-accent dark:focus:ring-blue-500 transition-all';
|
||||
@Input() initialQuery: string = '';
|
||||
|
||||
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
|
||||
|
||||
private historyService = inject(SearchHistoryService);
|
||||
|
||||
query = '';
|
||||
caseSensitive = signal(false);
|
||||
regexMode = signal(false);
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyIndex = -1;
|
||||
|
||||
constructor(private hostElement: ElementRef<HTMLElement>) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.initialQuery) {
|
||||
this.query = this.initialQuery;
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.anchorElement = this.hostElement.nativeElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input changes
|
||||
*/
|
||||
onInputChange(): void {
|
||||
this.queryChange.emit(this.query);
|
||||
this.historyIndex = -1;
|
||||
|
||||
// Update suggestions in assistant
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.currentQuery = this.query;
|
||||
this.assistantRef.updateSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus event - open assistant
|
||||
*/
|
||||
onFocus(): void {
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
*/
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
// Navigate history with up/down arrows when assistant is closed
|
||||
if (this.assistantRef && !this.assistantRef.isOpen()) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.navigateHistory('up');
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.navigateHistory('down');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Enter key - submit search
|
||||
*/
|
||||
onEnter(): void {
|
||||
if (this.query.trim()) {
|
||||
// Add to history
|
||||
this.historyService.add(this.context, this.query);
|
||||
|
||||
// Emit search event with options
|
||||
this.search.emit({
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key - close assistant
|
||||
*/
|
||||
onEscape(): void {
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query change from assistant
|
||||
*/
|
||||
onQueryChange(newQuery: string): void {
|
||||
this.query = newQuery;
|
||||
this.queryChange.emit(newQuery);
|
||||
|
||||
// Update input value
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = newQuery;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query submit from assistant
|
||||
*/
|
||||
onQuerySubmit(query: string): void {
|
||||
this.query = query;
|
||||
this.onEnter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle case sensitivity
|
||||
*/
|
||||
toggleCaseSensitivity(): void {
|
||||
this.caseSensitive.update(v => !v);
|
||||
|
||||
// Re-run search if there's a query
|
||||
if (this.query.trim()) {
|
||||
this.search.emit({
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle regex mode
|
||||
*/
|
||||
toggleRegexMode(): void {
|
||||
this.regexMode.update(v => !v);
|
||||
|
||||
// Re-run search if there's a query
|
||||
if (this.query.trim()) {
|
||||
this.search.emit({
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search
|
||||
*/
|
||||
clear(): void {
|
||||
this.query = '';
|
||||
this.queryChange.emit('');
|
||||
this.historyIndex = -1;
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = '';
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
// Emit empty search to clear results
|
||||
this.search.emit({
|
||||
query: '',
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate through search history
|
||||
*/
|
||||
private navigateHistory(direction: 'up' | 'down'): void {
|
||||
const history = this.historyService.list(this.context);
|
||||
if (history.length === 0) return;
|
||||
|
||||
if (direction === 'up') {
|
||||
this.historyIndex = Math.min(this.historyIndex + 1, history.length - 1);
|
||||
} else {
|
||||
this.historyIndex = Math.max(this.historyIndex - 1, -1);
|
||||
}
|
||||
|
||||
if (this.historyIndex >= 0) {
|
||||
this.query = history[this.historyIndex];
|
||||
this.queryChange.emit(this.query);
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = this.query;
|
||||
}
|
||||
} else {
|
||||
this.query = '';
|
||||
this.queryChange.emit('');
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the input
|
||||
*/
|
||||
focus(): void {
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the query programmatically
|
||||
*/
|
||||
setQuery(query: string): void {
|
||||
this.query = query;
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = query;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
AfterViewInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
|
||||
import { inject } from '@angular/core';
|
||||
import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
|
||||
/**
|
||||
* Search input with integrated query assistant
|
||||
* Wraps a text input and manages the search assistant popover
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-input-with-assistant',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, SearchQueryAssistantComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg
|
||||
*ngIf="showSearchIcon"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-text-muted"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
[placeholder]="placeholder"
|
||||
[value]="value"
|
||||
(input)="onInputChange($event)"
|
||||
(focus)="onFocus()"
|
||||
(blur)="onBlur()"
|
||||
(keydown.enter)="onEnter()"
|
||||
[class]="inputClass"
|
||||
[attr.aria-label]="placeholder"
|
||||
/>
|
||||
|
||||
<button
|
||||
*ngIf="value"
|
||||
(click)="clear()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<app-search-query-assistant
|
||||
#assistant
|
||||
[context]="context"
|
||||
[currentQuery]="value"
|
||||
[anchorElement]="anchorElement"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
(querySubmit)="onQuerySubmit($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
@Input() placeholder: string = 'Search...';
|
||||
@Input() value: string = '';
|
||||
@Input() context: string = 'default';
|
||||
@Input() showSearchIcon: boolean = true;
|
||||
@Input() inputClass: string = 'w-full px-3 py-2 text-sm border border-border rounded-md bg-bg-primary text-text-main placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-accent';
|
||||
|
||||
@Output() valueChange = new EventEmitter<string>();
|
||||
@Output() submit = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
|
||||
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyService = inject(SearchHistoryService);
|
||||
|
||||
constructor(private hostElement: ElementRef<HTMLElement>) {}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.anchorElement = this.hostElement.nativeElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input changes
|
||||
*/
|
||||
onInputChange(event: Event): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.value = target.value;
|
||||
this.valueChange.emit(this.value);
|
||||
|
||||
// Update suggestions in assistant
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.currentQuery = this.value;
|
||||
this.assistantRef.updateOptions();
|
||||
this.assistantRef.updateSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus event - open assistant
|
||||
*/
|
||||
onFocus(): void {
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle blur event
|
||||
*/
|
||||
onBlur(): void {
|
||||
// Delay close to allow clicking on popover items
|
||||
setTimeout(() => {
|
||||
if (this.assistantRef && !this.assistantRef.popoverRef) {
|
||||
// Only close if not interacting with popover
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Enter key
|
||||
*/
|
||||
onEnter(): void {
|
||||
// Save to history and submit
|
||||
if (this.value.trim()) {
|
||||
this.historyService.add(this.context, this.value);
|
||||
}
|
||||
this.submit.emit(this.value);
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.refreshHistoryView();
|
||||
this.assistantRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query change from assistant
|
||||
*/
|
||||
onQueryChange(query: string): void {
|
||||
this.value = query;
|
||||
this.valueChange.emit(query);
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = query;
|
||||
}
|
||||
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.currentQuery = query;
|
||||
this.assistantRef.updateOptions();
|
||||
this.assistantRef.updateSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query submit from assistant
|
||||
*/
|
||||
onQuerySubmit(query: string): void {
|
||||
this.value = query;
|
||||
if (query.trim()) {
|
||||
this.historyService.add(this.context, query);
|
||||
}
|
||||
this.submit.emit(query);
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = query;
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.currentQuery = query;
|
||||
this.assistantRef.refreshHistoryView();
|
||||
this.assistantRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the input
|
||||
*/
|
||||
clear(): void {
|
||||
this.value = '';
|
||||
this.valueChange.emit('');
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = '';
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the input
|
||||
*/
|
||||
focus(): void {
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
OnInit,
|
||||
inject,
|
||||
ChangeDetectionStrategy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SearchBarComponent } from '../search-bar/search-bar.component';
|
||||
import { SearchResultsComponent } from '../search-results/search-results.component';
|
||||
import { SearchEvaluatorService, SearchResult } from '../../core/search/search-evaluator.service';
|
||||
import { SearchIndexService } from '../../core/search/search-index.service';
|
||||
import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
|
||||
/**
|
||||
* Complete search panel with bar and results
|
||||
* Integrates search bar, results display, and search execution
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, SearchBarComponent, SearchResultsComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="flex flex-col h-full bg-bg-primary dark:bg-gray-900">
|
||||
<!-- Search bar -->
|
||||
<div class="p-4 border-b border-border dark:border-gray-700">
|
||||
<app-search-bar
|
||||
[placeholder]="placeholder"
|
||||
[context]="context"
|
||||
[showSearchIcon]="true"
|
||||
(search)="onSearch($event)"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-accent dark:border-blue-400"></div>
|
||||
<p class="text-sm text-text-muted dark:text-gray-400">Searching...</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (hasSearched() && results().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium mb-1">No results found</p>
|
||||
<p class="text-xs">Try adjusting your search query</p>
|
||||
</div>
|
||||
} @else if (results().length > 0) {
|
||||
<app-search-results
|
||||
[results]="results()"
|
||||
(noteOpen)="onNoteOpen($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">Start typing to search</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchPanelComponent implements OnInit {
|
||||
@Input() placeholder: string = 'Search in vault...';
|
||||
@Input() context: string = 'vault';
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
private searchEvaluator = inject(SearchEvaluatorService);
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
results = signal<SearchResult[]>([]);
|
||||
isSearching = signal(false);
|
||||
hasSearched = signal(false);
|
||||
currentQuery = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
// Build search index from vault notes
|
||||
this.rebuildIndex();
|
||||
|
||||
// Rebuild index when vault changes (could be optimized with incremental updates)
|
||||
// For now, we'll rebuild on init
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the search index
|
||||
*/
|
||||
private rebuildIndex(): void {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search execution
|
||||
*/
|
||||
onSearch(event: { query: string; options: SearchOptions }): void {
|
||||
const { query, options } = event;
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSearching.set(true);
|
||||
this.currentQuery.set(query);
|
||||
|
||||
// Execute search asynchronously to avoid blocking UI
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const searchResults = this.searchEvaluator.search(query, options);
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
} finally {
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query change (for live search if needed)
|
||||
*/
|
||||
onQueryChange(query: string): void {
|
||||
this.currentQuery.set(query);
|
||||
|
||||
// Could implement debounced live search here
|
||||
// For now, only search on Enter
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle note open from results
|
||||
*/
|
||||
onNoteOpen(event: { noteId: string; line?: number }): void {
|
||||
this.noteOpen.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the search index
|
||||
*/
|
||||
refreshIndex(): void {
|
||||
this.rebuildIndex();
|
||||
|
||||
// Re-run current search if there is one
|
||||
if (this.currentQuery()) {
|
||||
this.onSearch({
|
||||
query: this.currentQuery(),
|
||||
options: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
HostListener,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
import { SearchAssistantService, SearchOption } from '../../core/search/search-assistant.service';
|
||||
|
||||
type NavigationItem =
|
||||
| { type: 'option'; index: number; option: SearchOption }
|
||||
| { type: 'suggestion'; index: number; value: string }
|
||||
| { type: 'history'; index: number; value: string };
|
||||
|
||||
/**
|
||||
* Search query assistant component
|
||||
* Provides popover with search options, suggestions, and history
|
||||
* Based on Obsidian's search interface
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-query-assistant',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="relative">
|
||||
<!-- Popover -->
|
||||
@if (isOpen()) {
|
||||
<div
|
||||
#popover
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-bg-primary dark:bg-gray-800 border border-border dark:border-gray-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[360px]"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<!-- Search Options -->
|
||||
<div class="p-3 border-b border-border dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">Search options</h4>
|
||||
<button
|
||||
(click)="showHelp = !showHelp"
|
||||
class="p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
|
||||
title="Show help"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showHelp) {
|
||||
<div class="mb-2 p-2 bg-bg-muted dark:bg-gray-900 rounded-lg text-[11px] space-y-1.5">
|
||||
<div><strong>Operators:</strong></div>
|
||||
<div class="grid grid-cols-2 gap-1.5 text-text-muted dark:text-gray-400">
|
||||
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">OR</code> Either term</div>
|
||||
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">-term</code> Exclude</div>
|
||||
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">"phrase"</code> Exact match</div>
|
||||
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">term*</code> Wildcard</div>
|
||||
</div>
|
||||
<div><strong>Combine:</strong> <code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">tag:#todo OR tag:#urgent</code></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="space-y-1 max-h-[200px] overflow-y-auto transition-all pr-1">
|
||||
<button
|
||||
*ngFor="let option of searchOptions(); let i = index"
|
||||
(click)="insertOption(option.prefix)"
|
||||
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
|
||||
[ngClass]="{
|
||||
'bg-bg-muted': isSelected('option', i),
|
||||
'dark:bg-gray-700': isSelected('option', i)
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs font-mono text-accent dark:text-blue-400 whitespace-nowrap">{{ option.prefix }}</code>
|
||||
<div class="flex-1 min-w-0 text-[13px] text-text-muted dark:text-gray-400 group-hover:text-text-main dark:group-hover:text-gray-100">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
<span
|
||||
*ngIf="option.example"
|
||||
class="text-[11px] text-text-muted dark:text-gray-500 font-mono ml-2 truncate"
|
||||
>
|
||||
{{ option.example }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
@if (history().length > 0) {
|
||||
<div class="p-3 border-b border-border dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">History</h4>
|
||||
<button
|
||||
(click)="clearHistory()"
|
||||
class="text-[11px] text-text-muted dark:text-gray-400 hover:text-accent dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1 max-h-[150px] overflow-y-auto pr-1">
|
||||
<button
|
||||
*ngFor="let item of history(); let i = index"
|
||||
(click)="selectHistoryItem(item)"
|
||||
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
|
||||
[ngClass]="{
|
||||
'bg-bg-muted': isSelected('history', i),
|
||||
'dark:bg-gray-700': isSelected('history', i)
|
||||
}"
|
||||
>
|
||||
<span class="block truncate text-[13px] text-text-muted dark:text-gray-300 group-hover:text-text-main dark:group-hover:text-gray-100 font-mono">
|
||||
{{ item }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Dynamic Suggestions -->
|
||||
@if (suggestions().length > 0) {
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100 mb-2">
|
||||
{{ suggestionsTitle() }}
|
||||
</h4>
|
||||
<div class="space-y-1 max-h-[140px] overflow-y-auto pr-1">
|
||||
<button
|
||||
*ngFor="let suggestion of suggestions(); let i = index"
|
||||
(click)="insertSuggestion(suggestion)"
|
||||
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors text-sm"
|
||||
[ngClass]="{
|
||||
'bg-bg-muted': isSelected('suggestion', i),
|
||||
'dark:bg-gray-700': isSelected('suggestion', i)
|
||||
}"
|
||||
>
|
||||
<span class="block truncate text-[13px] text-text-main dark:text-gray-200">{{ suggestion }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchQueryAssistantComponent {
|
||||
@Input() context: string = 'default'; // Search context (vault, graph, etc.)
|
||||
@Input() currentQuery: string = '';
|
||||
@Input() anchorElement: HTMLElement | null = null;
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
@Output() querySubmit = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('popover') popoverRef?: ElementRef;
|
||||
|
||||
isOpen = signal(false);
|
||||
showHelp = false;
|
||||
selectedIndex = signal(-1);
|
||||
navigationItems = signal<NavigationItem[]>([]);
|
||||
history = signal<string[]>([]);
|
||||
|
||||
private historyService = inject(SearchHistoryService);
|
||||
private assistantService = inject(SearchAssistantService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
searchOptions = signal<SearchOption[]>([]);
|
||||
// Keep track of the original query when starting keyboard navigation previews
|
||||
private originalQueryForPreview: string | null = null;
|
||||
|
||||
/**
|
||||
* Open the assistant popover
|
||||
*/
|
||||
open(): void {
|
||||
this.isOpen.set(true);
|
||||
this.refreshHistory();
|
||||
this.updateOptions();
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the assistant popover
|
||||
*/
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
this.selectedIndex.set(-1);
|
||||
this.navigationItems.set([]);
|
||||
// Reset any preview state on close
|
||||
this.originalQueryForPreview = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle popover
|
||||
*/
|
||||
toggle(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* History items
|
||||
*/
|
||||
/**
|
||||
* Dynamic suggestions based on current query
|
||||
*/
|
||||
suggestions = signal<string[]>([]);
|
||||
|
||||
/**
|
||||
* Title for suggestions section
|
||||
*/
|
||||
suggestionsTitle = signal<string>('Suggestions');
|
||||
|
||||
/**
|
||||
* Update search options based on current query
|
||||
*/
|
||||
updateOptions(): void {
|
||||
const filtered = this.assistantService.getFilteredOptions(this.currentQuery);
|
||||
this.searchOptions.set(filtered);
|
||||
this.refreshNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update suggestions based on current query
|
||||
*/
|
||||
updateSuggestions(): void {
|
||||
const result = this.assistantService.getSuggestions(this.currentQuery);
|
||||
this.suggestions.set(result.items);
|
||||
this.suggestionsTitle.set(result.type);
|
||||
this.refreshNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a search option prefix
|
||||
*/
|
||||
insertOption(prefix: string): void {
|
||||
const optionValue = prefix === '[property]' ? '[' : prefix;
|
||||
const { newQuery } = this.assistantService.insertOption(
|
||||
this.currentQuery,
|
||||
optionValue,
|
||||
this.currentQuery.length
|
||||
);
|
||||
|
||||
this.currentQuery = newQuery;
|
||||
this.queryChange.emit(newQuery);
|
||||
this.updateOptions();
|
||||
this.updateSuggestions();
|
||||
this.selectedIndex.set(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a suggestion
|
||||
*/
|
||||
insertSuggestion(suggestion: string): void {
|
||||
const newQuery = this.assistantService.insertSuggestion(this.currentQuery, suggestion);
|
||||
this.currentQuery = newQuery;
|
||||
this.queryChange.emit(newQuery);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a history item
|
||||
*/
|
||||
selectHistoryItem(item: string): void {
|
||||
this.currentQuery = item;
|
||||
this.queryChange.emit(item);
|
||||
this.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.historyService.clear(this.context);
|
||||
this.refreshHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyboard(event: KeyboardEvent): void {
|
||||
if (!this.isOpen()) return;
|
||||
|
||||
const items = this.navigationItems();
|
||||
if (items.length === 0) {
|
||||
if (event.key === 'Enter') {
|
||||
this.querySubmit.emit(this.currentQuery);
|
||||
this.close();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const next = this.selectedIndex() + 1;
|
||||
this.selectedIndex.set(next >= items.length ? 0 : next);
|
||||
this.previewSelectedItem();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const prev = this.selectedIndex() - 1;
|
||||
this.selectedIndex.set(prev < 0 ? items.length - 1 : prev);
|
||||
this.previewSelectedItem();
|
||||
} else if (event.key === 'Enter' && this.selectedIndex() >= 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Commit the selection and clear preview baseline
|
||||
const applied = this.applySelectedItem();
|
||||
if (applied) {
|
||||
this.originalQueryForPreview = null;
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// Revert any previewed value
|
||||
if (this.originalQueryForPreview !== null) {
|
||||
this.currentQuery = this.originalQueryForPreview;
|
||||
this.queryChange.emit(this.currentQuery);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close on outside click
|
||||
*/
|
||||
@HostListener('document:click', ['$event'])
|
||||
handleOutsideClick(event: MouseEvent): void {
|
||||
if (!this.isOpen()) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const insideAssistant = this.elementRef.nativeElement.contains(target);
|
||||
const insideAnchor = this.anchorElement ? this.anchorElement.contains(target) : false;
|
||||
|
||||
if (!insideAssistant && !insideAnchor) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private refreshNavigation(): void {
|
||||
if (!this.isOpen()) {
|
||||
this.navigationItems.set([]);
|
||||
this.selectedIndex.set(-1);
|
||||
this.originalQueryForPreview = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const items: NavigationItem[] = [];
|
||||
|
||||
const options = this.searchOptions();
|
||||
options.forEach((option, index) => {
|
||||
items.push({ type: 'option', index, option });
|
||||
});
|
||||
|
||||
const historyItems = this.history();
|
||||
historyItems.forEach((value, index) => {
|
||||
items.push({ type: 'history', index, value });
|
||||
});
|
||||
|
||||
const suggestions = this.suggestions();
|
||||
suggestions.forEach((value, index) => {
|
||||
items.push({ type: 'suggestion', index, value });
|
||||
});
|
||||
|
||||
this.navigationItems.set(items);
|
||||
|
||||
if (items.length === 0) {
|
||||
this.selectedIndex.set(-1);
|
||||
} else if (this.selectedIndex() >= items.length) {
|
||||
this.selectedIndex.set(items.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshHistory(): void {
|
||||
const historyItems = this.historyService.list(this.context);
|
||||
this.history.set(historyItems);
|
||||
this.refreshNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to refresh history from parent component
|
||||
*/
|
||||
refreshHistoryView(): void {
|
||||
this.refreshHistory();
|
||||
}
|
||||
|
||||
applySelectedItem(): boolean {
|
||||
if (!this.isOpen()) return false;
|
||||
const index = this.selectedIndex();
|
||||
if (index < 0) return false;
|
||||
|
||||
const items = this.navigationItems();
|
||||
const selected = items[index];
|
||||
if (!selected) return false;
|
||||
|
||||
if (selected.type === 'suggestion') {
|
||||
this.insertSuggestion(selected.value);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selected.type === 'option') {
|
||||
this.insertOption(selected.option.prefix);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selected.type === 'history') {
|
||||
this.selectHistoryItem(selected.value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the currently selected item into the input without committing.
|
||||
* Restores original value on Escape or when closing.
|
||||
*/
|
||||
private previewSelectedItem(): void {
|
||||
const index = this.selectedIndex();
|
||||
const items = this.navigationItems();
|
||||
if (index < 0 || index >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the baseline only once at the start of navigation
|
||||
if (this.originalQueryForPreview === null) {
|
||||
this.originalQueryForPreview = this.currentQuery;
|
||||
}
|
||||
|
||||
const selected = items[index];
|
||||
let previewQuery = this.originalQueryForPreview;
|
||||
|
||||
if (selected.type === 'suggestion') {
|
||||
previewQuery = this.assistantService.insertSuggestion(this.originalQueryForPreview!, selected.value);
|
||||
} else if (selected.type === 'option') {
|
||||
const optionValue = selected.option.prefix === '[property]' ? '[' : selected.option.prefix;
|
||||
const { newQuery } = this.assistantService.insertOption(
|
||||
this.originalQueryForPreview!,
|
||||
optionValue,
|
||||
this.originalQueryForPreview!.length
|
||||
);
|
||||
previewQuery = newQuery;
|
||||
} else if (selected.type === 'history') {
|
||||
previewQuery = selected.value;
|
||||
}
|
||||
|
||||
this.currentQuery = previewQuery ?? this.currentQuery;
|
||||
this.queryChange.emit(this.currentQuery);
|
||||
}
|
||||
|
||||
isSelected(type: NavigationItem['type'], index: number): boolean {
|
||||
const selected = this.navigationItems()[this.selectedIndex()];
|
||||
if (!selected) return false;
|
||||
return selected.type === type && selected.index === index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchResult, SearchMatch } from '../../core/search/search-evaluator.service';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
|
||||
/**
|
||||
* Group of search results by file
|
||||
*/
|
||||
interface ResultGroup {
|
||||
noteId: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
matches: SearchMatch[];
|
||||
matchCount: number;
|
||||
isExpanded: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort option for results
|
||||
*/
|
||||
type SortOption = 'relevance' | 'name' | 'modified';
|
||||
|
||||
/**
|
||||
* Search results component
|
||||
* Displays search results grouped by file with highlighting and navigation
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-search-results',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="flex flex-col h-full bg-bg-primary dark:bg-gray-900">
|
||||
<!-- Results header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-border dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-sm font-semibold text-text-main dark:text-gray-100">
|
||||
{{ totalResults() }} {{ totalResults() === 1 ? 'result' : 'results' }}
|
||||
@if (totalMatches() > 0) {
|
||||
<span class="text-text-muted dark:text-gray-400">
|
||||
({{ totalMatches() }} {{ totalMatches() === 1 ? 'match' : 'matches' }})
|
||||
</span>
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Sort options -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-text-muted dark:text-gray-400">Sort:</label>
|
||||
<select
|
||||
[(ngModel)]="sortBy"
|
||||
(change)="onSortChange()"
|
||||
class="text-xs px-2 py-1 rounded border border-border dark:border-gray-600 bg-bg-primary dark:bg-gray-800 text-text-main dark:text-gray-100"
|
||||
>
|
||||
<option value="relevance">Relevance</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="modified">Modified</option>
|
||||
</select>
|
||||
|
||||
<!-- Expand/Collapse all -->
|
||||
<button
|
||||
(click)="toggleAllGroups()"
|
||||
class="text-xs px-2 py-1 rounded hover:bg-bg-muted dark:hover:bg-gray-700 text-text-muted dark:text-gray-400"
|
||||
title="Expand/Collapse all"
|
||||
>
|
||||
{{ allExpanded() ? 'Collapse all' : 'Expand all' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (sortedGroups().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-text-muted dark:text-gray-400 p-8">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-sm">No results found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="divide-y divide-border dark:divide-gray-700">
|
||||
@for (group of sortedGroups(); track group.noteId) {
|
||||
<div class="hover:bg-bg-muted dark:hover:bg-gray-800 transition-colors">
|
||||
<!-- File header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-3 cursor-pointer"
|
||||
(click)="toggleGroup(group)"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<!-- Expand/collapse icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-text-muted dark:text-gray-400 transition-transform"
|
||||
[class.rotate-90]="group.isExpanded"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
|
||||
<!-- File icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-accent dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
|
||||
<!-- File name and path -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-text-main dark:text-gray-100 truncate">
|
||||
{{ group.fileName }}
|
||||
</div>
|
||||
<div class="text-xs text-text-muted dark:text-gray-400 truncate">
|
||||
{{ group.filePath }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Match count badge -->
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-accent/10 dark:bg-blue-500/10 text-accent dark:text-blue-400">
|
||||
{{ group.matchCount }} {{ group.matchCount === 1 ? 'match' : 'matches' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Open button -->
|
||||
<button
|
||||
(click)="openNote(group.noteId, $event)"
|
||||
class="ml-2 p-1.5 rounded hover:bg-bg-primary dark:hover:bg-gray-700 text-text-muted dark:text-gray-400"
|
||||
title="Open note"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Matches (expanded) -->
|
||||
@if (group.isExpanded) {
|
||||
<div class="px-3 pb-3 pl-11 space-y-2">
|
||||
@for (match of group.matches; track $index) {
|
||||
<div
|
||||
class="p-2 rounded bg-bg-muted dark:bg-gray-800 hover:bg-bg-secondary dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
(click)="openNote(group.noteId, $event, match.line)"
|
||||
>
|
||||
<!-- Match type badge -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-bg-primary dark:bg-gray-900 text-text-muted dark:text-gray-400 font-mono">
|
||||
{{ match.type }}
|
||||
</span>
|
||||
@if (match.line) {
|
||||
<span class="text-xs text-text-muted dark:text-gray-500">
|
||||
Line {{ match.line }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Match context with highlighting -->
|
||||
<div class="text-sm text-text-main dark:text-gray-200 font-mono leading-relaxed">
|
||||
<span [innerHTML]="highlightMatch(match.context, match.text)"></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchResultsComponent {
|
||||
@Input() set results(value: SearchResult[]) {
|
||||
this.buildGroups(value);
|
||||
}
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
private groups = signal<ResultGroup[]>([]);
|
||||
sortBy: SortOption = 'relevance';
|
||||
|
||||
/**
|
||||
* Total number of result files
|
||||
*/
|
||||
totalResults = computed(() => this.groups().length);
|
||||
|
||||
/**
|
||||
* Total number of matches across all files
|
||||
*/
|
||||
totalMatches = computed(() => {
|
||||
return this.groups().reduce((sum, group) => sum + group.matchCount, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if all groups are expanded
|
||||
*/
|
||||
allExpanded = computed(() => {
|
||||
const groups = this.groups();
|
||||
return groups.length > 0 && groups.every(g => g.isExpanded);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sorted groups based on current sort option
|
||||
*/
|
||||
sortedGroups = computed(() => {
|
||||
const groups = [...this.groups()];
|
||||
|
||||
switch (this.sortBy) {
|
||||
case 'relevance':
|
||||
return groups.sort((a, b) => b.score - a.score);
|
||||
|
||||
case 'name':
|
||||
return groups.sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
|
||||
case 'modified':
|
||||
// Would need modification time from vault service
|
||||
return groups;
|
||||
|
||||
default:
|
||||
return groups;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Build result groups from search results
|
||||
*/
|
||||
private buildGroups(results: SearchResult[]): void {
|
||||
const groups: ResultGroup[] = results.map(result => {
|
||||
// Get note info from vault service
|
||||
const note = this.vaultService.getNoteById(result.noteId);
|
||||
const fileName = note?.fileName || result.noteId;
|
||||
const filePath = note?.filePath || result.noteId;
|
||||
|
||||
return {
|
||||
noteId: result.noteId,
|
||||
fileName,
|
||||
filePath,
|
||||
matches: result.matches,
|
||||
matchCount: result.matches.length,
|
||||
isExpanded: true, // Expand by default
|
||||
score: result.score
|
||||
};
|
||||
});
|
||||
|
||||
this.groups.set(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a result group
|
||||
*/
|
||||
toggleGroup(group: ResultGroup): void {
|
||||
this.groups.update(groups => {
|
||||
return groups.map(g =>
|
||||
g.noteId === group.noteId
|
||||
? { ...g, isExpanded: !g.isExpanded }
|
||||
: g
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all groups
|
||||
*/
|
||||
toggleAllGroups(): void {
|
||||
const shouldExpand = !this.allExpanded();
|
||||
this.groups.update(groups => {
|
||||
return groups.map(g => ({ ...g, isExpanded: shouldExpand }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sort change
|
||||
*/
|
||||
onSortChange(): void {
|
||||
// Sorting is handled by computed property
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a note
|
||||
*/
|
||||
openNote(noteId: string, event: Event, line?: number): void {
|
||||
event.stopPropagation();
|
||||
this.noteOpen.emit({ noteId, line });
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matched text in context
|
||||
*/
|
||||
highlightMatch(context: string, matchText: string): string {
|
||||
if (!matchText || !context) {
|
||||
return this.escapeHtml(context);
|
||||
}
|
||||
|
||||
const escapedContext = this.escapeHtml(context);
|
||||
const escapedMatch = this.escapeHtml(matchText);
|
||||
|
||||
// Case-insensitive replacement with highlighting
|
||||
const regex = new RegExp(`(${escapedMatch})`, 'gi');
|
||||
return escapedContext.replace(
|
||||
regex,
|
||||
'<mark class="bg-yellow-200 dark:bg-yellow-600 text-text-main dark:text-gray-900 px-0.5 rounded">$1</mark>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Index of vault content for graph view
|
||||
* Maintains lists of files, tags, paths, attachments for fast filtering
|
||||
*/
|
||||
export interface GraphIndexData {
|
||||
/** All files (markdown notes) */
|
||||
files: FileIndexEntry[];
|
||||
/** All unique tags found in the vault */
|
||||
tags: string[];
|
||||
/** All unique paths (folders) */
|
||||
paths: string[];
|
||||
/** All attachments (non-markdown files) */
|
||||
attachments: AttachmentIndexEntry[];
|
||||
/** Map of file ID to tags */
|
||||
fileToTags: Map<string, string[]>;
|
||||
/** Map of file ID to path */
|
||||
fileToPath: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface FileIndexEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface AttachmentIndexEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphIndexService {
|
||||
private indexData = signal<GraphIndexData>({
|
||||
files: [],
|
||||
tags: [],
|
||||
paths: [],
|
||||
attachments: [],
|
||||
fileToTags: new Map(),
|
||||
fileToPath: new Map()
|
||||
});
|
||||
|
||||
/**
|
||||
* Current index data as signal
|
||||
*/
|
||||
get index() {
|
||||
return this.indexData.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild index from vault data
|
||||
*/
|
||||
rebuildIndex(notes: any[]): void {
|
||||
const files: FileIndexEntry[] = [];
|
||||
const tagsSet = new Set<string>();
|
||||
const pathsSet = new Set<string>();
|
||||
const attachments: AttachmentIndexEntry[] = [];
|
||||
const fileToTags = new Map<string, string[]>();
|
||||
const fileToPath = new Map<string, string>();
|
||||
|
||||
for (const note of notes) {
|
||||
// Extract path components
|
||||
const filePath = note.filePath || note.path || '';
|
||||
const pathParts = filePath.split('/');
|
||||
const fileName = pathParts[pathParts.length - 1] || '';
|
||||
const folderPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Add to paths
|
||||
if (folderPath) {
|
||||
pathsSet.add(folderPath);
|
||||
// Also add parent paths
|
||||
const parentParts = folderPath.split('/');
|
||||
for (let i = 1; i <= parentParts.length; i++) {
|
||||
pathsSet.add(parentParts.slice(0, i).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's an attachment (non-markdown file)
|
||||
const isAttachment = this.isAttachmentFile(fileName);
|
||||
|
||||
if (isAttachment) {
|
||||
const extension = this.getFileExtension(fileName);
|
||||
attachments.push({
|
||||
id: note.id,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
extension
|
||||
});
|
||||
} else {
|
||||
// Regular markdown file
|
||||
const tags = note.tags || [];
|
||||
|
||||
// Add tags to set (normalize to include #)
|
||||
tags.forEach((tag: string) => {
|
||||
const normalized = tag.startsWith('#') ? tag : `#${tag}`;
|
||||
tagsSet.add(normalized);
|
||||
});
|
||||
|
||||
files.push({
|
||||
id: note.id,
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
tags
|
||||
});
|
||||
|
||||
fileToTags.set(note.id, tags);
|
||||
}
|
||||
|
||||
fileToPath.set(note.id, filePath);
|
||||
}
|
||||
|
||||
// Update index
|
||||
this.indexData.set({
|
||||
files,
|
||||
tags: Array.from(tagsSet).sort(),
|
||||
paths: Array.from(pathsSet).sort(),
|
||||
attachments,
|
||||
fileToTags,
|
||||
fileToPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions based on query type
|
||||
*/
|
||||
getSuggestions(type: 'path' | 'tag' | 'file' | 'property', filter?: string): string[] {
|
||||
const index = this.indexData();
|
||||
const filterLower = filter?.toLowerCase() || '';
|
||||
|
||||
switch (type) {
|
||||
case 'path':
|
||||
return index.paths.filter(p => !filter || p.toLowerCase().includes(filterLower));
|
||||
|
||||
case 'tag':
|
||||
return index.tags.filter(t => !filter || t.toLowerCase().includes(filterLower));
|
||||
|
||||
case 'file':
|
||||
return index.files
|
||||
.filter(f => !filter || f.name.toLowerCase().includes(filterLower))
|
||||
.map(f => f.name)
|
||||
.slice(0, 50); // Limit to 50 suggestions
|
||||
|
||||
case 'property':
|
||||
// Common Obsidian properties
|
||||
const commonProps = ['title', 'tags', 'aliases', 'description', 'author', 'date', 'created', 'modified'];
|
||||
return commonProps.filter(p => !filter || p.toLowerCase().includes(filterLower));
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is an attachment (non-markdown)
|
||||
*/
|
||||
private isAttachmentFile(fileName: string): boolean {
|
||||
const attachmentExtensions = [
|
||||
'pdf', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp',
|
||||
'mp4', 'webm', 'ogv', 'mov', 'mkv',
|
||||
'mp3', 'wav', 'ogg', 'm4a', 'flac',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'zip', 'rar', '7z', 'tar', 'gz'
|
||||
];
|
||||
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return attachmentExtensions.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension (lowercase, no dot)
|
||||
*/
|
||||
private getFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot === -1) return '';
|
||||
return fileName.substring(lastDot + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# Search Module
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { SearchPanelComponent } from './components/search-panel/search-panel.component';
|
||||
|
||||
// In your component template
|
||||
<app-search-panel
|
||||
placeholder="Search in vault..."
|
||||
context="vault"
|
||||
(noteOpen)="openNote($event)"
|
||||
/>
|
||||
```
|
||||
|
||||
### Standalone Search Bar
|
||||
|
||||
```typescript
|
||||
import { SearchBarComponent } from './components/search-bar/search-bar.component';
|
||||
|
||||
<app-search-bar
|
||||
placeholder="Search..."
|
||||
context="my-context"
|
||||
(search)="onSearch($event)"
|
||||
/>
|
||||
```
|
||||
|
||||
## Supported Operators
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `file:` | Match file name | `file:.jpg` |
|
||||
| `path:` | Match file path | `path:"Daily notes"` |
|
||||
| `content:` | Match in content | `content:"hello"` |
|
||||
| `tag:` | Search tags | `tag:#work` |
|
||||
| `line:` | Same line | `line:(mix flour)` |
|
||||
| `block:` | Same block | `block:(dog cat)` |
|
||||
| `section:` | Same section | `section:(intro)` |
|
||||
| `task:` | In tasks | `task:call` |
|
||||
| `task-todo:` | Uncompleted tasks | `task-todo:review` |
|
||||
| `task-done:` | Completed tasks | `task-done:meeting` |
|
||||
| `match-case:` | Case-sensitive | `match-case:API` |
|
||||
| `ignore-case:` | Case-insensitive | `ignore-case:test` |
|
||||
| `[property]` | Property exists | `[description]` |
|
||||
| `[property:value]` | Property value | `[status]:"draft"` |
|
||||
|
||||
## Boolean Operators
|
||||
|
||||
- **AND** (implicit): `term1 term2`
|
||||
- **OR**: `term1 OR term2`
|
||||
- **NOT**: `-term`
|
||||
- **Grouping**: `(term1 OR term2) term3`
|
||||
|
||||
## Special Syntax
|
||||
|
||||
- **Exact phrase**: `"hello world"`
|
||||
- **Wildcard**: `test*`
|
||||
- **Regex**: `/\d{4}/`
|
||||
|
||||
## Services
|
||||
|
||||
### SearchIndexService
|
||||
|
||||
Indexes vault content for fast searching.
|
||||
|
||||
```typescript
|
||||
constructor(private searchIndex: SearchIndexService) {}
|
||||
|
||||
// Rebuild index
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
|
||||
// Get suggestions
|
||||
const suggestions = this.searchIndex.getSuggestions('tag', '#');
|
||||
```
|
||||
|
||||
### SearchEvaluatorService
|
||||
|
||||
Executes search queries.
|
||||
|
||||
```typescript
|
||||
constructor(private evaluator: SearchEvaluatorService) {}
|
||||
|
||||
// Search
|
||||
const results = this.evaluator.search('tag:#work', {
|
||||
caseSensitive: false
|
||||
});
|
||||
```
|
||||
|
||||
### SearchAssistantService
|
||||
|
||||
Provides autocomplete and suggestions.
|
||||
|
||||
```typescript
|
||||
constructor(private assistant: SearchAssistantService) {}
|
||||
|
||||
// Get filtered options
|
||||
const options = this.assistant.getFilteredOptions('pa');
|
||||
|
||||
// Get contextual suggestions
|
||||
const suggestions = this.assistant.getSuggestions('path:proj');
|
||||
```
|
||||
|
||||
### SearchHistoryService
|
||||
|
||||
Manages search history.
|
||||
|
||||
```typescript
|
||||
constructor(private history: SearchHistoryService) {}
|
||||
|
||||
// Add to history
|
||||
this.history.add('vault', 'my query');
|
||||
|
||||
// Get history
|
||||
const items = this.history.list('vault');
|
||||
|
||||
// Clear history
|
||||
this.history.clear('vault');
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### SearchBarComponent
|
||||
|
||||
Main search input with Aa and .* buttons.
|
||||
|
||||
**Inputs:**
|
||||
- `placeholder: string` - Placeholder text
|
||||
- `context: string` - History context
|
||||
- `showSearchIcon: boolean` - Show search icon
|
||||
- `inputClass: string` - Custom CSS classes
|
||||
- `initialQuery: string` - Initial query value
|
||||
|
||||
**Outputs:**
|
||||
- `search: { query: string; options: SearchOptions }` - Search event
|
||||
- `queryChange: string` - Query change event
|
||||
|
||||
### SearchResultsComponent
|
||||
|
||||
Displays search results with grouping.
|
||||
|
||||
**Inputs:**
|
||||
- `results: SearchResult[]` - Search results
|
||||
|
||||
**Outputs:**
|
||||
- `noteOpen: { noteId: string; line?: number }` - Note open event
|
||||
|
||||
### SearchPanelComponent
|
||||
|
||||
Complete search UI (bar + results).
|
||||
|
||||
**Inputs:**
|
||||
- `placeholder: string` - Placeholder text
|
||||
- `context: string` - History context
|
||||
|
||||
**Outputs:**
|
||||
- `noteOpen: { noteId: string; line?: number }` - Note open event
|
||||
|
||||
## Examples
|
||||
|
||||
### Complex Query
|
||||
|
||||
```typescript
|
||||
const query = `
|
||||
path:projects/
|
||||
tag:#active
|
||||
(Python OR JavaScript)
|
||||
-deprecated
|
||||
file:".md"
|
||||
match-case:"API"
|
||||
`;
|
||||
|
||||
const results = this.evaluator.search(query, {
|
||||
caseSensitive: false,
|
||||
regexMode: false
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Search Context
|
||||
|
||||
```typescript
|
||||
import { SearchContext } from './search-parser.types';
|
||||
|
||||
const context: SearchContext = {
|
||||
filePath: 'notes/test.md',
|
||||
fileName: 'test',
|
||||
fileNameWithExt: 'test.md',
|
||||
content: 'Hello world',
|
||||
tags: ['#test'],
|
||||
properties: { status: 'draft' },
|
||||
lines: ['Hello world'],
|
||||
blocks: ['Hello world'],
|
||||
sections: [{ heading: 'Title', content: 'Hello world', level: 1 }],
|
||||
tasks: [{ text: 'Do something', completed: false, line: 1 }]
|
||||
};
|
||||
|
||||
const predicate = queryToPredicate(parsed);
|
||||
const matches = predicate(context);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Debounce input**: Avoid searching on every keystroke
|
||||
2. **Limit results**: Cap results at reasonable number (e.g., 100)
|
||||
3. **Incremental indexing**: Update index incrementally instead of full rebuild
|
||||
4. **Lazy loading**: Load results as user scrolls
|
||||
|
||||
## Testing
|
||||
|
||||
```typescript
|
||||
import { parseSearchQuery } from './search-parser';
|
||||
|
||||
describe('Search Parser', () => {
|
||||
it('should parse file operator', () => {
|
||||
const parsed = parseSearchQuery('file:test.md');
|
||||
expect(parsed.isEmpty).toBe(false);
|
||||
// Assert AST structure
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { detectQueryType } from './search-parser';
|
||||
import { SearchIndexService } from './search-index.service';
|
||||
|
||||
/**
|
||||
* Search option definition
|
||||
*/
|
||||
export interface SearchOption {
|
||||
prefix: string;
|
||||
description: string;
|
||||
example?: string;
|
||||
category: 'field' | 'scope' | 'modifier' | 'property';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search assistant service
|
||||
* Provides intelligent suggestions and autocomplete for search queries
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchAssistantService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
|
||||
/**
|
||||
* All available search options
|
||||
*/
|
||||
private readonly allOptions: SearchOption[] = [
|
||||
// Field operators
|
||||
{ prefix: 'file:', description: 'Match in file name', example: 'file:.jpg', category: 'field' },
|
||||
{ prefix: 'path:', description: 'Match in file path', example: 'path:"Daily notes"', category: 'field' },
|
||||
{ prefix: 'content:', description: 'Match in content', example: 'content:"happy cat"', category: 'field' },
|
||||
{ prefix: 'tag:', description: 'Search for tags', example: 'tag:#work', category: 'field' },
|
||||
|
||||
// Scope operators
|
||||
{ prefix: 'line:', description: 'Keywords on same line', example: 'line:(mix flour)', category: 'scope' },
|
||||
{ prefix: 'block:', description: 'Keywords in same block', example: 'block:(dog cat)', category: 'scope' },
|
||||
{ prefix: 'section:', description: 'Keywords under same heading', example: 'section:(dog cat)', category: 'scope' },
|
||||
|
||||
// Task operators
|
||||
{ prefix: 'task:', description: 'Search in tasks', example: 'task:call', category: 'field' },
|
||||
{ prefix: 'task-todo:', description: 'Search uncompleted tasks', example: 'task-todo:call', category: 'field' },
|
||||
{ prefix: 'task-done:', description: 'Search completed tasks', example: 'task-done:call', category: 'field' },
|
||||
|
||||
// Case modifiers
|
||||
{ prefix: 'match-case:', description: 'Case-sensitive search', example: 'match-case:HappyCat', category: 'modifier' },
|
||||
{ prefix: 'ignore-case:', description: 'Case-insensitive search', example: 'ignore-case:ikea', category: 'modifier' },
|
||||
|
||||
// Property
|
||||
{ prefix: '[property]', description: 'Match frontmatter property', example: '[description]:"page"', category: 'property' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get filtered search options based on current input
|
||||
*/
|
||||
getFilteredOptions(query: string): SearchOption[] {
|
||||
if (!query || !query.trim()) {
|
||||
return this.allOptions;
|
||||
}
|
||||
|
||||
const lastToken = this.getLastToken(query);
|
||||
if (!lastToken) {
|
||||
return this.allOptions;
|
||||
}
|
||||
|
||||
const lowerToken = lastToken.toLowerCase();
|
||||
|
||||
// Filter options that start with or contain the token
|
||||
return this.allOptions.filter(option => {
|
||||
const prefix = option.prefix.toLowerCase();
|
||||
return prefix.startsWith(lowerToken) || prefix.includes(lowerToken);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual suggestions based on the current query
|
||||
*/
|
||||
getSuggestions(query: string): { type: string; items: string[] } {
|
||||
const detected = detectQueryType(query);
|
||||
|
||||
switch (detected.type) {
|
||||
case 'path':
|
||||
return {
|
||||
type: 'Paths',
|
||||
items: this.searchIndex.getSuggestions('path', detected.value)
|
||||
};
|
||||
|
||||
case 'file':
|
||||
return {
|
||||
type: 'Files',
|
||||
items: this.searchIndex.getSuggestions('file', detected.value)
|
||||
};
|
||||
|
||||
case 'tag':
|
||||
return {
|
||||
type: 'Tags',
|
||||
items: this.searchIndex.getSuggestions('tag', detected.value)
|
||||
};
|
||||
|
||||
case 'section':
|
||||
return {
|
||||
type: 'Headings',
|
||||
items: this.searchIndex.getSuggestions('section', detected.value)
|
||||
};
|
||||
|
||||
case 'task':
|
||||
case 'task-todo':
|
||||
case 'task-done':
|
||||
return {
|
||||
type: 'Task keywords',
|
||||
items: this.searchIndex.getSuggestions('task', detected.value)
|
||||
};
|
||||
|
||||
case 'property':
|
||||
if (detected.prefix) {
|
||||
// Suggesting property values
|
||||
return {
|
||||
type: `Values for "${detected.prefix}"`,
|
||||
items: this.searchIndex.getSuggestions('property', detected.value, detected.prefix)
|
||||
};
|
||||
} else {
|
||||
// Suggesting property keys
|
||||
return {
|
||||
type: 'Properties',
|
||||
items: this.searchIndex.getSuggestions('property', detected.value)
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { type: '', items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an option into the query at the cursor position
|
||||
*/
|
||||
insertOption(query: string, option: string, cursorPosition: number): { newQuery: string; newCursorPosition: number } {
|
||||
const lastToken = this.getLastToken(query);
|
||||
|
||||
if (!lastToken) {
|
||||
// Insert at cursor
|
||||
const before = query.substring(0, cursorPosition);
|
||||
const after = query.substring(cursorPosition);
|
||||
const newQuery = before + option + after;
|
||||
return {
|
||||
newQuery,
|
||||
newCursorPosition: cursorPosition + option.length
|
||||
};
|
||||
}
|
||||
|
||||
// Replace last token
|
||||
const lastTokenIndex = query.lastIndexOf(lastToken);
|
||||
const before = query.substring(0, lastTokenIndex);
|
||||
const after = query.substring(lastTokenIndex + lastToken.length);
|
||||
const newQuery = before + option + after;
|
||||
|
||||
return {
|
||||
newQuery,
|
||||
newCursorPosition: before.length + option.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a suggestion value into the query
|
||||
*/
|
||||
insertSuggestion(query: string, suggestion: string): string {
|
||||
const detected = detectQueryType(query);
|
||||
|
||||
// Add quotes if the suggestion contains spaces
|
||||
const needsQuotes = suggestion.includes(' ') || suggestion.includes('/');
|
||||
const quotedSuggestion = needsQuotes ? `"${suggestion}"` : suggestion;
|
||||
|
||||
switch (detected.type) {
|
||||
case 'path':
|
||||
return `path:${quotedSuggestion}`;
|
||||
|
||||
case 'file':
|
||||
return `file:${quotedSuggestion}`;
|
||||
|
||||
case 'tag':
|
||||
// Ensure tag starts with #
|
||||
const tagValue = suggestion.startsWith('#') ? suggestion : `#${suggestion}`;
|
||||
return `tag:${tagValue}`;
|
||||
|
||||
case 'section':
|
||||
return `section:${quotedSuggestion}`;
|
||||
|
||||
case 'task':
|
||||
return `task:${quotedSuggestion}`;
|
||||
|
||||
case 'task-todo':
|
||||
return `task-todo:${quotedSuggestion}`;
|
||||
|
||||
case 'task-done':
|
||||
return `task-done:${quotedSuggestion}`;
|
||||
|
||||
case 'property':
|
||||
if (detected.prefix) {
|
||||
// Inserting property value
|
||||
return `[${detected.prefix}:${quotedSuggestion}]`;
|
||||
} else {
|
||||
// Inserting property key
|
||||
return `[${suggestion}]`;
|
||||
}
|
||||
|
||||
default:
|
||||
return suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last token from the query
|
||||
*/
|
||||
private getLastToken(query: string): string {
|
||||
const trimmed = query.trim();
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
return tokens[tokens.length - 1] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get help text for a specific operator
|
||||
*/
|
||||
getHelpText(operator: string): string {
|
||||
const option = this.allOptions.find(o => o.prefix === operator);
|
||||
if (option) {
|
||||
return `${option.description}${option.example ? ` (e.g., ${option.example})` : ''}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a search query
|
||||
*/
|
||||
validateQuery(query: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for unmatched quotes
|
||||
const quoteCount = (query.match(/"/g) || []).length;
|
||||
if (quoteCount % 2 !== 0) {
|
||||
errors.push('Unmatched quote');
|
||||
}
|
||||
|
||||
// Check for unmatched parentheses
|
||||
let parenDepth = 0;
|
||||
for (const char of query) {
|
||||
if (char === '(') parenDepth++;
|
||||
if (char === ')') parenDepth--;
|
||||
if (parenDepth < 0) {
|
||||
errors.push('Unmatched closing parenthesis');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parenDepth > 0) {
|
||||
errors.push('Unmatched opening parenthesis');
|
||||
}
|
||||
|
||||
// Check for unmatched brackets
|
||||
const openBrackets = (query.match(/\[/g) || []).length;
|
||||
const closeBrackets = (query.match(/\]/g) || []).length;
|
||||
if (openBrackets !== closeBrackets) {
|
||||
errors.push('Unmatched bracket');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { parseSearchQuery, queryToPredicate } from './search-parser';
|
||||
import { SearchOptions } from './search-parser.types';
|
||||
import { SearchIndexService } from './search-index.service';
|
||||
|
||||
/**
|
||||
* Result of a search query
|
||||
*/
|
||||
export interface SearchResult {
|
||||
noteId: string;
|
||||
matches: SearchMatch[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual match within a note
|
||||
*/
|
||||
export interface SearchMatch {
|
||||
type: 'content' | 'heading' | 'task' | 'property';
|
||||
text: string;
|
||||
context: string;
|
||||
line?: number;
|
||||
startOffset?: number;
|
||||
endOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search evaluator service
|
||||
* Executes search queries against the indexed vault
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchEvaluatorService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
|
||||
/**
|
||||
* Execute a search query and return matching notes
|
||||
*/
|
||||
search(query: string, options?: SearchOptions): SearchResult[] {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse the query into an AST
|
||||
const parsed = parseSearchQuery(query, options);
|
||||
if (parsed.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to predicate function
|
||||
const predicate = queryToPredicate(parsed, options);
|
||||
|
||||
// Evaluate against all indexed contexts
|
||||
const results: SearchResult[] = [];
|
||||
const allContexts = this.searchIndex.getAllContexts();
|
||||
|
||||
for (const context of allContexts) {
|
||||
if (predicate(context)) {
|
||||
// Find matches within the content
|
||||
const matches = this.findMatches(context, query);
|
||||
const score = this.calculateScore(context, query, matches);
|
||||
|
||||
results.push({
|
||||
noteId: context.filePath, // Using filePath as noteId
|
||||
matches,
|
||||
score
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find specific matches within a context
|
||||
*/
|
||||
private findMatches(context: any, query: string): SearchMatch[] {
|
||||
const matches: SearchMatch[] = [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Simple keyword extraction (can be enhanced)
|
||||
const keywords = this.extractKeywords(query);
|
||||
|
||||
// Search in content
|
||||
keywords.forEach(keyword => {
|
||||
const keywordLower = keyword.toLowerCase();
|
||||
let index = 0;
|
||||
const contentLower = context.content.toLowerCase();
|
||||
|
||||
while ((index = contentLower.indexOf(keywordLower, index)) !== -1) {
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(context.content.length, index + keyword.length + 50);
|
||||
const contextText = context.content.substring(start, end);
|
||||
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: keyword,
|
||||
context: (start > 0 ? '...' : '') + contextText + (end < context.content.length ? '...' : ''),
|
||||
startOffset: index,
|
||||
endOffset: index + keyword.length
|
||||
});
|
||||
|
||||
index += keyword.length;
|
||||
}
|
||||
});
|
||||
|
||||
// Search in headings
|
||||
context.sections?.forEach((section: any) => {
|
||||
keywords.forEach(keyword => {
|
||||
if (section.heading.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
matches.push({
|
||||
type: 'heading',
|
||||
text: keyword,
|
||||
context: section.heading
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Search in tasks
|
||||
context.tasks?.forEach((task: any, index: number) => {
|
||||
keywords.forEach(keyword => {
|
||||
if (task.text.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
matches.push({
|
||||
type: 'task',
|
||||
text: keyword,
|
||||
context: task.text,
|
||||
line: task.line
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Limit matches to avoid overwhelming results
|
||||
return matches.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract keywords from query for highlighting
|
||||
*/
|
||||
private extractKeywords(query: string): string[] {
|
||||
const keywords: string[] = [];
|
||||
|
||||
// Remove operators and extract actual search terms
|
||||
const cleaned = query
|
||||
.replace(/\b(AND|OR|NOT)\b/gi, '')
|
||||
.replace(/[()]/g, ' ')
|
||||
.replace(/-\w+/g, '') // Remove negated terms
|
||||
.replace(/\w+:/g, ''); // Remove prefixes
|
||||
|
||||
// Extract quoted phrases
|
||||
const quotedMatches = cleaned.match(/"([^"]+)"/g);
|
||||
if (quotedMatches) {
|
||||
quotedMatches.forEach(match => {
|
||||
keywords.push(match.replace(/"/g, ''));
|
||||
});
|
||||
}
|
||||
|
||||
// Extract individual words (longer than 2 chars)
|
||||
const words = cleaned
|
||||
.replace(/"[^"]+"/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 2);
|
||||
|
||||
keywords.push(...words);
|
||||
|
||||
return Array.from(new Set(keywords)); // Deduplicate
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relevance score for a match
|
||||
*/
|
||||
private calculateScore(context: any, query: string, matches: SearchMatch[]): number {
|
||||
let score = 0;
|
||||
|
||||
// Base score from number of matches
|
||||
score += matches.length * 10;
|
||||
|
||||
// Bonus for matches in headings
|
||||
const headingMatches = matches.filter(m => m.type === 'heading');
|
||||
score += headingMatches.length * 20;
|
||||
|
||||
// Bonus for matches in file name
|
||||
const queryLower = query.toLowerCase();
|
||||
if (context.fileName.toLowerCase().includes(queryLower)) {
|
||||
score += 50;
|
||||
}
|
||||
|
||||
// Bonus for exact phrase matches
|
||||
if (context.content.toLowerCase().includes(queryLower)) {
|
||||
score += 30;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Service for managing search history using localStorage
|
||||
* Each search context (vault search, graph search, etc.) has its own history
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchHistoryService {
|
||||
private readonly MAX_HISTORY = 10;
|
||||
private readonly STORAGE_PREFIX = 'obsidian-search-history-';
|
||||
|
||||
/**
|
||||
* Add a query to history for a specific context
|
||||
*/
|
||||
add(context: string, query: string): void {
|
||||
if (!query || !query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.getStorageKey(context);
|
||||
const history = this.list(context);
|
||||
|
||||
// Remove if already exists (to move to top)
|
||||
const filtered = history.filter(q => q !== query);
|
||||
|
||||
// Add to beginning
|
||||
filtered.unshift(query);
|
||||
|
||||
// Limit to MAX_HISTORY
|
||||
const limited = filtered.slice(0, this.MAX_HISTORY);
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(limited));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history list for a specific context
|
||||
*/
|
||||
list(context: string): string[] {
|
||||
const key = this.getStorageKey(context);
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear history for a specific context
|
||||
*/
|
||||
clear(context: string): void {
|
||||
const key = this.getStorageKey(context);
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific query from history
|
||||
*/
|
||||
remove(context: string, query: string): void {
|
||||
const key = this.getStorageKey(context);
|
||||
const history = this.list(context);
|
||||
const filtered = history.filter(q => q !== query);
|
||||
localStorage.setItem(key, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage key for a context
|
||||
*/
|
||||
private getStorageKey(context: string): string {
|
||||
return `${this.STORAGE_PREFIX}${context}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { SearchContext, SectionContent, TaskInfo } from './search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Comprehensive search index for the vault
|
||||
* Indexes all content for fast searching with full Obsidian operator support
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SearchIndexService {
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
private indexData = signal<Map<string, SearchContext>>(new Map());
|
||||
private pathsIndex = signal<string[]>([]);
|
||||
private filesIndex = signal<string[]>([]);
|
||||
private tagsIndex = signal<string[]>([]);
|
||||
private propertiesIndex = signal<Map<string, Set<string>>>(new Map());
|
||||
private headingsIndex = signal<Map<string, string[]>>(new Map());
|
||||
|
||||
/**
|
||||
* Get all indexed paths
|
||||
*/
|
||||
get paths() {
|
||||
return this.pathsIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed files
|
||||
*/
|
||||
get files() {
|
||||
return this.filesIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed tags
|
||||
*/
|
||||
get tags() {
|
||||
return this.tagsIndex.asReadonly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all indexed property keys
|
||||
*/
|
||||
get propertyKeys() {
|
||||
return computed(() => Array.from(this.propertiesIndex().keys()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entire search index from vault notes
|
||||
*/
|
||||
rebuildIndex(notes: Note[]): void {
|
||||
const contextMap = new Map<string, SearchContext>();
|
||||
const pathsSet = new Set<string>();
|
||||
const filesSet = new Set<string>();
|
||||
const tagsSet = new Set<string>();
|
||||
const propertiesMap = new Map<string, Set<string>>();
|
||||
const headingsMap = new Map<string, string[]>();
|
||||
|
||||
for (const note of notes) {
|
||||
// Build search context for this note
|
||||
const context = this.buildSearchContext(note);
|
||||
contextMap.set(note.id, context);
|
||||
|
||||
// Index paths
|
||||
const pathParts = context.filePath.split('/');
|
||||
for (let i = 1; i < pathParts.length; i++) {
|
||||
pathsSet.add(pathParts.slice(0, i).join('/'));
|
||||
}
|
||||
if (context.filePath) {
|
||||
pathsSet.add(context.filePath);
|
||||
}
|
||||
|
||||
// Index files
|
||||
filesSet.add(context.fileName);
|
||||
filesSet.add(context.fileNameWithExt);
|
||||
|
||||
// Index tags
|
||||
context.tags.forEach(tag => tagsSet.add(tag));
|
||||
|
||||
// Index properties
|
||||
Object.keys(context.properties).forEach(key => {
|
||||
if (!propertiesMap.has(key)) {
|
||||
propertiesMap.set(key, new Set());
|
||||
}
|
||||
const value = context.properties[key];
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => propertiesMap.get(key)!.add(String(v)));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
propertiesMap.get(key)!.add(String(value));
|
||||
}
|
||||
});
|
||||
|
||||
// Index headings
|
||||
const headings = context.sections.map(s => s.heading).filter(h => h);
|
||||
if (headings.length > 0) {
|
||||
headingsMap.set(note.id, headings);
|
||||
}
|
||||
}
|
||||
|
||||
this.indexData.set(contextMap);
|
||||
this.pathsIndex.set(Array.from(pathsSet).sort());
|
||||
this.filesIndex.set(Array.from(filesSet).sort());
|
||||
this.tagsIndex.set(Array.from(tagsSet).sort());
|
||||
this.propertiesIndex.set(propertiesMap);
|
||||
this.headingsIndex.set(headingsMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search context for a specific note
|
||||
*/
|
||||
getContext(noteId: string): SearchContext | undefined {
|
||||
return this.indexData().get(noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all search contexts
|
||||
*/
|
||||
getAllContexts(): SearchContext[] {
|
||||
return Array.from(this.indexData().values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions for a specific query type
|
||||
*/
|
||||
getSuggestions(
|
||||
type: 'path' | 'file' | 'tag' | 'property' | 'section' | 'task',
|
||||
filter?: string,
|
||||
propertyKey?: string
|
||||
): string[] {
|
||||
const filterLower = filter?.toLowerCase() || '';
|
||||
|
||||
switch (type) {
|
||||
case 'path':
|
||||
return this.pathsIndex()
|
||||
.filter(p => !filter || p.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'file':
|
||||
return this.filesIndex()
|
||||
.filter(f => !filter || f.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'tag':
|
||||
return this.tagsIndex()
|
||||
.filter(t => !filter || t.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'property':
|
||||
if (propertyKey) {
|
||||
// Get property values for a specific key
|
||||
const values = this.propertiesIndex().get(propertyKey);
|
||||
if (values) {
|
||||
return Array.from(values)
|
||||
.filter(v => !filter || v.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
}
|
||||
return [];
|
||||
} else {
|
||||
// Get property keys
|
||||
return Array.from(this.propertiesIndex().keys())
|
||||
.filter(k => !filter || k.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
case 'section':
|
||||
// Get all unique headings
|
||||
const allHeadings = new Set<string>();
|
||||
this.headingsIndex().forEach(headings => {
|
||||
headings.forEach(h => allHeadings.add(h));
|
||||
});
|
||||
return Array.from(allHeadings)
|
||||
.filter(h => !filter || h.toLowerCase().includes(filterLower))
|
||||
.slice(0, 50);
|
||||
|
||||
case 'task':
|
||||
// Get common task keywords from indexed tasks
|
||||
const taskKeywords = new Set<string>();
|
||||
this.indexData().forEach(context => {
|
||||
context.tasks.forEach(task => {
|
||||
// Extract words from task text
|
||||
const words = task.text.split(/\s+/).filter(w => w.length > 2);
|
||||
words.forEach(w => taskKeywords.add(w));
|
||||
});
|
||||
});
|
||||
return Array.from(taskKeywords)
|
||||
.filter(k => !filter || k.toLowerCase().includes(filterLower))
|
||||
.slice(0, 30);
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build search context from a note
|
||||
*/
|
||||
private buildSearchContext(note: Note): SearchContext {
|
||||
const content = note.content || '';
|
||||
const rawContent = note.rawContent || content;
|
||||
|
||||
// Extract file info
|
||||
const filePath = note.filePath || note.originalPath || '';
|
||||
const fileNameWithExt = note.fileName || '';
|
||||
const fileName = fileNameWithExt.replace(/\.md$/i, '');
|
||||
|
||||
// Split content into lines
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Extract blocks (paragraphs separated by blank lines)
|
||||
const blocks = this.extractBlocks(content);
|
||||
|
||||
// Extract sections
|
||||
const sections = this.extractSections(content);
|
||||
|
||||
// Extract tasks
|
||||
const tasks = this.extractTasks(rawContent);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileName,
|
||||
fileNameWithExt,
|
||||
content,
|
||||
tags: note.tags || [],
|
||||
properties: note.frontmatter || {},
|
||||
lines,
|
||||
blocks,
|
||||
sections,
|
||||
tasks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blocks (paragraphs) from content
|
||||
*/
|
||||
private extractBlocks(content: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let currentBlock = '';
|
||||
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') {
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock.trim());
|
||||
currentBlock = '';
|
||||
}
|
||||
} else {
|
||||
currentBlock += (currentBlock ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push(currentBlock.trim());
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sections from content
|
||||
*/
|
||||
private extractSections(content: string): SectionContent[] {
|
||||
const sections: SectionContent[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
let currentHeading = '';
|
||||
let currentLevel = 0;
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headingMatch) {
|
||||
// Save previous section
|
||||
if (currentHeading || currentContent.length > 0) {
|
||||
sections.push({
|
||||
heading: currentHeading,
|
||||
content: currentContent.join('\n'),
|
||||
level: currentLevel
|
||||
});
|
||||
}
|
||||
|
||||
// Start new section
|
||||
currentLevel = headingMatch[1].length;
|
||||
currentHeading = headingMatch[2].trim();
|
||||
currentContent = [];
|
||||
} else {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last section
|
||||
if (currentHeading || currentContent.length > 0) {
|
||||
sections.push({
|
||||
heading: currentHeading,
|
||||
content: currentContent.join('\n'),
|
||||
level: currentLevel
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tasks from content
|
||||
*/
|
||||
private extractTasks(content: string): TaskInfo[] {
|
||||
const tasks: TaskInfo[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Match task items: - [ ] or - [x] or - [X]
|
||||
const taskMatch = line.match(/^[\s-]*\[([xX\s])\]\s*(.+)$/);
|
||||
if (taskMatch) {
|
||||
const completed = taskMatch[1].toLowerCase() === 'x';
|
||||
const text = taskMatch[2].trim();
|
||||
tasks.push({
|
||||
text,
|
||||
completed,
|
||||
line: index + 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { detectQueryType, parseSearchQuery, queryToPredicate } from './search-parser';
|
||||
import { SearchContext } from './search-parser.types';
|
||||
|
||||
type TestFn = () => void;
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const testResults: TestResult[] = [];
|
||||
|
||||
const runTest = (name: string, fn: TestFn): void => {
|
||||
try {
|
||||
fn();
|
||||
testResults.push({ name });
|
||||
} catch (error) {
|
||||
testResults.push({ name, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
console.error(`[search-parser spec] ${name} failed`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const assert = (condition: boolean, message: string): void => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const assertEqual = <T>(actual: T, expected: T, message: string): void => {
|
||||
assert(actual === expected, `${message} (expected ${expected}, received ${actual})`);
|
||||
};
|
||||
|
||||
const createContext = (overrides: Partial<SearchContext> = {}): SearchContext => ({
|
||||
filePath: 'notes/example.md',
|
||||
fileName: 'example',
|
||||
fileNameWithExt: 'example.md',
|
||||
content: 'Hello world with #tag in section heading',
|
||||
tags: ['#tag'],
|
||||
properties: { author: 'Ada', status: 'draft' },
|
||||
lines: ['Hello world with #tag', 'Another line'],
|
||||
blocks: ['Hello world with #tag in section heading'],
|
||||
sections: [{ heading: 'Section', content: 'Hello world', level: 1 }],
|
||||
tasks: [
|
||||
{ text: 'Call Bob', completed: false, line: 3 },
|
||||
{ text: 'Review PR', completed: true, line: 5 }
|
||||
],
|
||||
...overrides
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery marks empty query as empty', () => {
|
||||
const parsed = parseSearchQuery('');
|
||||
assert(parsed.isEmpty, 'Empty query should be flagged as empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Empty query AST should be an empty group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles basic text queries', () => {
|
||||
const parsed = parseSearchQuery('hello world');
|
||||
assert(!parsed.isEmpty, 'Non-empty query should not be marked empty');
|
||||
assertEqual(parsed.ast.type, 'group', 'Text query AST should be a group');
|
||||
});
|
||||
|
||||
runTest('parseSearchQuery handles operators', () => {
|
||||
const parsed = parseSearchQuery('path:notes/ tag:#tag file:example');
|
||||
assert(!parsed.isEmpty, 'Operator query should parse successfully');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches simple text', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('hello'));
|
||||
assert(predicate(createContext()), 'Predicate should match when text is present');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate respects negation', () => {
|
||||
const predicate = queryToPredicate(parseSearchQuery('-deprecated'));
|
||||
assert(predicate(createContext()), 'Predicate should pass when term is absent');
|
||||
assert(!predicate(createContext({ content: 'deprecated feature' })), 'Predicate should fail when negated term exists');
|
||||
});
|
||||
|
||||
runTest('queryToPredicate matches task scopes', () => {
|
||||
const todoPredicate = queryToPredicate(parseSearchQuery('task-todo:Call'));
|
||||
const donePredicate = queryToPredicate(parseSearchQuery('task-done:Review'));
|
||||
assert(todoPredicate(createContext()), 'task-todo should match incomplete tasks');
|
||||
assert(donePredicate(createContext()), 'task-done should match completed tasks');
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises path prefix', () => {
|
||||
const info = detectQueryType('path:notes');
|
||||
assertEqual(info.type, 'path', 'Should recognise path prefix');
|
||||
assertEqual(info.value, 'notes', 'Should capture path value');
|
||||
});
|
||||
|
||||
runTest('detectQueryType recognises property bracket', () => {
|
||||
const info = detectQueryType('[status:');
|
||||
assertEqual(info.type, 'property', 'Should recognise property prefix');
|
||||
});
|
||||
|
||||
runTest('detectQueryType falls back to general type', () => {
|
||||
const info = detectQueryType('plain text');
|
||||
assertEqual(info.type, 'general', 'General text should return general type');
|
||||
});
|
||||
|
||||
export { testResults };
|
||||
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* Obsidian-compatible search query parser
|
||||
* Full operator support: path:, file:, content:, tag:, line:, block:, section:,
|
||||
* task:, task-todo:, task-done:, match-case:, ignore-case:, [property], OR, AND, -, *, "", /regex/
|
||||
*/
|
||||
|
||||
import {
|
||||
SearchTerm,
|
||||
SearchGroup,
|
||||
SearchNode,
|
||||
ParsedQuery,
|
||||
SearchPredicate,
|
||||
SearchContext,
|
||||
SearchOptions,
|
||||
SectionContent,
|
||||
TaskInfo
|
||||
} from './search-parser.types';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { SearchContext, SearchOptions, SectionContent, TaskInfo };
|
||||
|
||||
/**
|
||||
* Parse an Obsidian search query into an AST
|
||||
*/
|
||||
export function parseSearchQuery(query: string, options?: SearchOptions): ParsedQuery {
|
||||
if (!query || !query.trim()) {
|
||||
return {
|
||||
ast: { type: 'group', operator: 'AND', terms: [] },
|
||||
isEmpty: true
|
||||
};
|
||||
}
|
||||
|
||||
const tokens = tokenize(query);
|
||||
const ast = parseTokens(tokens, options);
|
||||
|
||||
return {
|
||||
ast,
|
||||
isEmpty: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parsed query into a predicate function
|
||||
*/
|
||||
export function queryToPredicate(parsed: ParsedQuery, options?: SearchOptions): SearchPredicate {
|
||||
if (parsed.isEmpty) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
return (context: SearchContext) => evaluateNode(parsed.ast, context, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the query string
|
||||
*/
|
||||
function tokenize(query: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < query.length) {
|
||||
const char = query[i];
|
||||
|
||||
// Handle quoted strings
|
||||
if (char === '"') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
let quoted = '';
|
||||
i++;
|
||||
while (i < query.length && query[i] !== '"') {
|
||||
quoted += query[i];
|
||||
i++;
|
||||
}
|
||||
if (quoted) {
|
||||
tokens.push(`"${quoted}"`);
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regex patterns /.../
|
||||
if (char === '/' && (current === '' || current.endsWith(' '))) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
let regex = '/';
|
||||
i++;
|
||||
while (i < query.length && query[i] !== '/') {
|
||||
regex += query[i];
|
||||
i++;
|
||||
}
|
||||
if (i < query.length) {
|
||||
regex += '/';
|
||||
i++;
|
||||
}
|
||||
if (regex.length > 2) {
|
||||
tokens.push(regex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle parentheses
|
||||
if (char === '(' || char === ')') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
tokens.push(char);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle spaces
|
||||
if (char === ' ') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens.filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tokens into AST
|
||||
*/
|
||||
function parseTokens(tokens: string[], options?: SearchOptions): SearchNode {
|
||||
const terms: SearchNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
|
||||
// Handle OR operator
|
||||
if (token.toUpperCase() === 'OR') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle AND operator (implicit)
|
||||
if (token.toUpperCase() === 'AND') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle parentheses
|
||||
if (token === '(') {
|
||||
const { node, endIndex } = parseGroup(tokens, i + 1, options);
|
||||
terms.push(node);
|
||||
i = endIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse term
|
||||
const term = parseTerm(token, options);
|
||||
if (term) {
|
||||
terms.push(term);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Determine operator based on OR presence
|
||||
const hasOr = tokens.some(t => t.toUpperCase() === 'OR');
|
||||
const operator = hasOr ? 'OR' : 'AND';
|
||||
|
||||
return {
|
||||
type: 'group',
|
||||
operator,
|
||||
terms
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a group enclosed in parentheses
|
||||
*/
|
||||
function parseGroup(tokens: string[], startIndex: number, options?: SearchOptions): { node: SearchNode; endIndex: number } {
|
||||
const terms: SearchNode[] = [];
|
||||
let i = startIndex;
|
||||
let depth = 1;
|
||||
|
||||
while (i < tokens.length && depth > 0) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token === '(') {
|
||||
depth++;
|
||||
} else if (token === ')') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (token.toUpperCase() !== 'OR' && token.toUpperCase() !== 'AND' && token !== '(' && token !== ')') {
|
||||
const term = parseTerm(token, options);
|
||||
if (term) {
|
||||
terms.push(term);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
const hasOr = tokens.slice(startIndex, i).some(t => t.toUpperCase() === 'OR');
|
||||
const operator = hasOr ? 'OR' : 'AND';
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: 'group',
|
||||
operator,
|
||||
terms
|
||||
},
|
||||
endIndex: i
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single search term
|
||||
*/
|
||||
function parseTerm(token: string, options?: SearchOptions): SearchTerm | null {
|
||||
if (!token) return null;
|
||||
|
||||
let negated = false;
|
||||
let value = token;
|
||||
|
||||
// Handle negation
|
||||
if (value.startsWith('-')) {
|
||||
negated = true;
|
||||
value = value.substring(1);
|
||||
}
|
||||
|
||||
// Handle regex patterns /.../
|
||||
if (value.startsWith('/') && value.endsWith('/') && value.length > 2) {
|
||||
const regexPattern = value.substring(1, value.length - 1);
|
||||
return { type: 'regex', value: regexPattern, negated, quoted: false, wildcard: false };
|
||||
}
|
||||
|
||||
// Handle quoted strings
|
||||
let quoted = false;
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
quoted = true;
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
|
||||
// Handle wildcards
|
||||
const wildcard = value.includes('*');
|
||||
|
||||
// Check for prefixes
|
||||
const colonIndex = value.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const prefix = value.substring(0, colonIndex).toLowerCase();
|
||||
const searchValue = value.substring(colonIndex + 1);
|
||||
|
||||
// Remove quotes from search value if present
|
||||
let cleanValue = searchValue;
|
||||
let valueQuoted = false;
|
||||
if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
|
||||
valueQuoted = true;
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length - 1);
|
||||
}
|
||||
|
||||
switch (prefix) {
|
||||
case 'path':
|
||||
return { type: 'path', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'file':
|
||||
return { type: 'file', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'content':
|
||||
return { type: 'content', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'tag':
|
||||
return { type: 'tag', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'line':
|
||||
return { type: 'line', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'block':
|
||||
return { type: 'block', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'section':
|
||||
return { type: 'section', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task':
|
||||
return { type: 'task', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task-todo':
|
||||
return { type: 'task-todo', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'task-done':
|
||||
return { type: 'task-done', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*') };
|
||||
case 'match-case':
|
||||
return { type: 'match-case', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*'), caseSensitive: true };
|
||||
case 'ignore-case':
|
||||
return { type: 'ignore-case', value: cleanValue, negated, quoted: valueQuoted, wildcard: cleanValue.includes('*'), caseSensitive: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for property search [property:value] or [property]
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
const inner = value.substring(1, value.length - 1);
|
||||
const propColonIndex = inner.indexOf(':');
|
||||
if (propColonIndex > 0) {
|
||||
const propertyKey = inner.substring(0, propColonIndex);
|
||||
let propertyValue = inner.substring(propColonIndex + 1);
|
||||
let propValueQuoted = false;
|
||||
|
||||
// Remove quotes from property value
|
||||
if (propertyValue.startsWith('"') && propertyValue.endsWith('"')) {
|
||||
propValueQuoted = true;
|
||||
propertyValue = propertyValue.substring(1, propertyValue.length - 1);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'property',
|
||||
value: propertyValue,
|
||||
propertyKey,
|
||||
negated,
|
||||
quoted: propValueQuoted,
|
||||
wildcard: propertyValue.includes('*')
|
||||
};
|
||||
}
|
||||
// Property existence check [property]
|
||||
return {
|
||||
type: 'property',
|
||||
value: '',
|
||||
propertyKey: inner,
|
||||
negated,
|
||||
quoted: false,
|
||||
wildcard: false
|
||||
};
|
||||
}
|
||||
|
||||
// Default: text search
|
||||
return { type: 'text', value, negated, quoted, wildcard };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a search node against context
|
||||
*/
|
||||
function evaluateNode(node: SearchNode, context: SearchContext, options?: SearchOptions): boolean {
|
||||
if (node.type === 'group') {
|
||||
return evaluateGroup(node, context, options);
|
||||
}
|
||||
return evaluateTerm(node, context, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a group node
|
||||
*/
|
||||
function evaluateGroup(group: SearchGroup, context: SearchContext, options?: SearchOptions): boolean {
|
||||
if (group.terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const results = group.terms.map(term => evaluateNode(term, context, options));
|
||||
|
||||
if (group.operator === 'OR') {
|
||||
return results.some(r => r);
|
||||
}
|
||||
|
||||
// AND operator (default)
|
||||
return results.every(r => r);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single term
|
||||
*/
|
||||
function evaluateTerm(term: SearchTerm, context: SearchContext, options?: SearchOptions): boolean {
|
||||
let result = false;
|
||||
|
||||
// Determine case sensitivity for this term
|
||||
const caseSensitive = term.caseSensitive !== undefined
|
||||
? term.caseSensitive
|
||||
: (options?.caseSensitive || false);
|
||||
|
||||
switch (term.type) {
|
||||
case 'path':
|
||||
result = matchString(context.filePath, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
result = matchString(context.fileName, term.value, term.wildcard, caseSensitive) ||
|
||||
matchString(context.fileNameWithExt, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'content':
|
||||
result = matchString(context.content, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
|
||||
case 'tag':
|
||||
const searchTag = term.value.startsWith('#') ? term.value.substring(1) : term.value;
|
||||
result = context.tags.some(tag => {
|
||||
const cleanTag = tag.startsWith('#') ? tag.substring(1) : tag;
|
||||
return matchString(cleanTag, searchTag, term.wildcard, caseSensitive);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'line':
|
||||
result = context.lines.some(line => matchString(line, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'block':
|
||||
result = context.blocks.some(block => matchString(block, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'section':
|
||||
result = context.sections.some(section =>
|
||||
matchString(section.content, term.value, term.wildcard, caseSensitive) ||
|
||||
matchString(section.heading, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
result = context.tasks.some(task => matchString(task.text, term.value, term.wildcard, caseSensitive));
|
||||
break;
|
||||
|
||||
case 'task-todo':
|
||||
result = context.tasks.some(task =>
|
||||
!task.completed && matchString(task.text, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'task-done':
|
||||
result = context.tasks.some(task =>
|
||||
task.completed && matchString(task.text, term.value, term.wildcard, caseSensitive)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'match-case':
|
||||
// This is a text search with forced case sensitivity
|
||||
result = matchString(context.content, term.value, term.wildcard, true);
|
||||
break;
|
||||
|
||||
case 'ignore-case':
|
||||
// This is a text search with forced case insensitivity
|
||||
result = matchString(context.content, term.value, term.wildcard, false);
|
||||
break;
|
||||
|
||||
case 'regex':
|
||||
try {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(term.value, flags);
|
||||
result = regex.test(context.content);
|
||||
} catch (e) {
|
||||
// Invalid regex, no match
|
||||
result = false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'property':
|
||||
if (term.propertyKey) {
|
||||
const propValue = context.properties[term.propertyKey];
|
||||
if (term.value === '') {
|
||||
// Property existence check
|
||||
result = propValue !== undefined;
|
||||
} else {
|
||||
// Property value check
|
||||
if (Array.isArray(propValue)) {
|
||||
result = propValue.some(v => matchString(String(v), term.value, term.wildcard, caseSensitive));
|
||||
} else {
|
||||
result = matchString(String(propValue || ''), term.value, term.wildcard, caseSensitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
// Search in content
|
||||
result = matchString(context.content, term.value, term.wildcard, caseSensitive);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply negation
|
||||
return term.negated ? !result : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a string with optional wildcard support and case sensitivity
|
||||
*/
|
||||
function matchString(text: string, pattern: string, wildcard: boolean = false, caseSensitive: boolean = false): boolean {
|
||||
if (!caseSensitive) {
|
||||
const textLower = text.toLowerCase();
|
||||
const patternLower = pattern.toLowerCase();
|
||||
|
||||
if (!wildcard) {
|
||||
return textLower.includes(patternLower);
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
const regexPattern = patternLower
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(regexPattern, 'i');
|
||||
return regex.test(text);
|
||||
} else {
|
||||
// Case sensitive matching
|
||||
if (!wildcard) {
|
||||
return text.includes(pattern);
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex (case sensitive)
|
||||
const regexPattern = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
|
||||
.replace(/\*/g, '.*'); // Convert * to .*
|
||||
|
||||
const regex = new RegExp(regexPattern);
|
||||
return regex.test(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract search operators/prefixes from a partial query
|
||||
* Used for autocomplete suggestions
|
||||
*/
|
||||
export function detectQueryType(query: string): {
|
||||
type: 'path' | 'file' | 'content' | 'tag' | 'line' | 'block' | 'section' | 'task' | 'task-todo' | 'task-done' | 'match-case' | 'ignore-case' | 'property' | 'general' | null;
|
||||
prefix: string;
|
||||
value: string;
|
||||
} {
|
||||
const trimmed = query.trim();
|
||||
|
||||
// Check for property
|
||||
if (trimmed.startsWith('[')) {
|
||||
const closeBracket = trimmed.indexOf(']');
|
||||
if (closeBracket === -1) {
|
||||
// Still typing property
|
||||
const inner = trimmed.substring(1);
|
||||
const colonIndex = inner.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
return { type: 'property', prefix: inner.substring(0, colonIndex), value: inner.substring(colonIndex + 1) };
|
||||
}
|
||||
return { type: 'property', prefix: '', value: inner };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard prefixes
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const prefix = trimmed.substring(0, colonIndex).toLowerCase();
|
||||
const value = trimmed.substring(colonIndex + 1);
|
||||
|
||||
const validPrefixes = [
|
||||
'path', 'file', 'content', 'tag', 'line', 'block', 'section',
|
||||
'task', 'task-todo', 'task-done', 'match-case', 'ignore-case'
|
||||
];
|
||||
|
||||
if (validPrefixes.includes(prefix)) {
|
||||
return { type: prefix as any, prefix, value };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'general', prefix: '', value: trimmed };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Types for Obsidian-compatible search query parsing
|
||||
* Full operator support: path:, file:, content:, tag:, line:, block:, section:,
|
||||
* task:, task-todo:, task-done:, match-case:, ignore-case:, [property], OR, AND, -, *, "", /regex/
|
||||
*/
|
||||
|
||||
export type SearchOperator = 'AND' | 'OR' | 'NOT';
|
||||
|
||||
export type SearchTermType =
|
||||
| 'text'
|
||||
| 'path'
|
||||
| 'file'
|
||||
| 'content'
|
||||
| 'tag'
|
||||
| 'line'
|
||||
| 'block'
|
||||
| 'section'
|
||||
| 'task'
|
||||
| 'task-todo'
|
||||
| 'task-done'
|
||||
| 'match-case'
|
||||
| 'ignore-case'
|
||||
| 'property'
|
||||
| 'regex';
|
||||
|
||||
export interface SearchTerm {
|
||||
type: SearchTermType;
|
||||
value: string;
|
||||
propertyKey?: string; // For property searches like [title:value]
|
||||
quoted?: boolean;
|
||||
wildcard?: boolean;
|
||||
negated?: boolean;
|
||||
caseSensitive?: boolean; // Local case sensitivity override
|
||||
}
|
||||
|
||||
export interface SearchGroup {
|
||||
type: 'group';
|
||||
operator: SearchOperator;
|
||||
terms: (SearchTerm | SearchGroup)[];
|
||||
}
|
||||
|
||||
export type SearchNode = SearchTerm | SearchGroup;
|
||||
|
||||
export interface ParsedQuery {
|
||||
ast: SearchNode;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/** Predicate function to test if content matches the query */
|
||||
export type SearchPredicate = (context: SearchContext) => boolean;
|
||||
|
||||
/** Context provided to search predicates */
|
||||
export interface SearchContext {
|
||||
/** File path (e.g., "folder/note.md") */
|
||||
filePath: string;
|
||||
/** File name without extension (e.g., "note") */
|
||||
fileName: string;
|
||||
/** File name with extension (e.g., "note.md") */
|
||||
fileNameWithExt: string;
|
||||
/** Content of the file */
|
||||
content: string;
|
||||
/** Tags in the file */
|
||||
tags: string[];
|
||||
/** YAML frontmatter properties */
|
||||
properties: Record<string, any>;
|
||||
/** Lines of content */
|
||||
lines: string[];
|
||||
/** Blocks of content (paragraphs separated by blank lines) */
|
||||
blocks: string[];
|
||||
/** Sections with their content (text between headings) */
|
||||
sections: SectionContent[];
|
||||
/** Tasks in the file */
|
||||
tasks: TaskInfo[];
|
||||
}
|
||||
|
||||
/** Section content with heading */
|
||||
export interface SectionContent {
|
||||
heading: string;
|
||||
content: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** Task information */
|
||||
export interface TaskInfo {
|
||||
text: string;
|
||||
completed: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Search options for case sensitivity and regex */
|
||||
export interface SearchOptions {
|
||||
/** Global case sensitivity (default: false = case-insensitive) */
|
||||
caseSensitive?: boolean;
|
||||
/** Enable regex mode */
|
||||
regexMode?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user