feat: add multi-select notes and Gemini AI integration
- Implemented multi-selection for notes with Ctrl+click, long-press, and keyboard shortcuts (Ctrl+A, Escape) - Added Gemini API integration with environment configuration and routes - Enhanced code block UI with improved copy feedback animation and visual polish - Added sort order toggle (asc/desc) for note lists with persistent state
This commit is contained in:
+11
-3
@@ -420,6 +420,7 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in oklab, var(--md-pre-bg) 85%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--md-pre-border) 85%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:host-context(.dark) ::ng-deep .code-block__header {
|
||||
@@ -520,7 +521,7 @@
|
||||
:host ::ng-deep .code-block__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem; /* match visual distance between wrap, language, and feedback */
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -545,12 +546,17 @@
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__copy-feedback {
|
||||
font-size: 0.68rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.72rem; /* slightly larger for readability like mock */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--md-pre-fg);
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
width: 0; /* collapse so it doesn't push the badge */
|
||||
overflow: hidden;
|
||||
margin-left: 0;
|
||||
transition: opacity 0.18s ease, width 0.18s ease, margin-left 0.18s ease;
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block.copied {
|
||||
@@ -560,6 +566,8 @@
|
||||
|
||||
:host ::ng-deep .code-block.copied .code-block__copy-feedback {
|
||||
opacity: 1;
|
||||
width: auto; /* expand to natural width when visible */
|
||||
margin-left: 0.75rem; /* spacing to the right of the badge */
|
||||
}
|
||||
|
||||
:host ::ng-deep .code-block__body {
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Output,
|
||||
Input,
|
||||
ChangeDetectionStrategy,
|
||||
HostListener,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { trigger, transition, style, animate } from '@angular/animations';
|
||||
import { GeminiService, GeminiTask, GeminiTaskType } from '../../services/gemini.service';
|
||||
import type { Note } from '../../../types';
|
||||
|
||||
/**
|
||||
* Composant du panneau d'interface IA Gemini
|
||||
*
|
||||
* Ce composant affiche une interface utilisateur moderne pour interagir avec
|
||||
* les fonctionnalités d'intelligence artificielle d'ObsiViewer.
|
||||
*
|
||||
* @features
|
||||
* - Affichage des tâches IA disponibles
|
||||
* - Exécution de tâches avec feedback visuel
|
||||
* - Animation de progression
|
||||
* - Gestion des erreurs
|
||||
* - Support complet des thèmes ObsiViewer
|
||||
*
|
||||
* @usage
|
||||
* ```html
|
||||
* <app-gemini-panel
|
||||
* *ngIf="showGeminiPanel"
|
||||
* [selectedNote]="selectedNote"
|
||||
* (close)="showGeminiPanel = false">
|
||||
* </app-gemini-panel>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-gemini-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger('fadeInOut', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
animate('200ms ease-in', style({ opacity: 1 }))
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate('200ms ease-out', style({ opacity: 0 }))
|
||||
])
|
||||
]),
|
||||
trigger('scaleIn', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0, transform: 'scale(0.95)' }),
|
||||
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' }))
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate('150ms ease-in', style({ opacity: 0, transform: 'scale(0.95)' }))
|
||||
])
|
||||
]),
|
||||
trigger('slideIn', [
|
||||
transition(':enter', [
|
||||
style({ opacity: 0, transform: 'translateY(10px)' }),
|
||||
animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
|
||||
])
|
||||
])
|
||||
],
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
@fadeInOut
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
(click)="onBackdropClick()">
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
@scaleIn
|
||||
class="bg-card dark:bg-main border border-border dark:border-gray-700 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden relative flex flex-col"
|
||||
(click)="$event.stopPropagation()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="relative px-8 pt-8 pb-6 border-b border-border dark:border-gray-700">
|
||||
<!-- Background Gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-purple-500/10 via-blue-500/10 to-pink-500/10 dark:from-purple-500/5 dark:via-blue-500/5 dark:to-pink-500/5"></div>
|
||||
|
||||
<div class="relative flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Icon -->
|
||||
<div class="w-14 h-14 rounded-xl bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center shadow-lg">
|
||||
<span class="text-3xl">🤖</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-main dark:text-white mb-1">IA Gemini</h1>
|
||||
<p class="text-sm text-muted">Assistant intelligent pour vos notes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full hover:bg-surface1 dark:hover:bg-card transition-colors flex items-center justify-center text-muted hover:text-main"
|
||||
(click)="close.emit()"
|
||||
aria-label="Fermer">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Note Info -->
|
||||
<div *ngIf="selectedNote" class="mt-4 px-4 py-3 rounded-lg bg-surface1/50 dark:bg-card/50 border border-border/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted">Note sélectionnée:</span>
|
||||
<span class="font-medium text-main dark:text-white truncate">{{ selectedNote.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-6" style="scrollbar-width: thin;">
|
||||
|
||||
<!-- Status/Progress Section -->
|
||||
<div *ngIf="execution()" @slideIn class="mb-6">
|
||||
<div class="p-4 rounded-xl border"
|
||||
[class.bg-blue-50]="execution()!.status === 'running'"
|
||||
[class.dark:bg-blue-950/30]="execution()!.status === 'running'"
|
||||
[class.border-blue-200]="execution()!.status === 'running'"
|
||||
[class.dark:border-blue-800]="execution()!.status === 'running'"
|
||||
[class.bg-green-50]="execution()!.status === 'success'"
|
||||
[class.dark:bg-green-950/30]="execution()!.status === 'success'"
|
||||
[class.border-green-200]="execution()!.status === 'success'"
|
||||
[class.dark:border-green-800]="execution()!.status === 'success'"
|
||||
[class.bg-red-50]="execution()!.status === 'error'"
|
||||
[class.dark:bg-red-950/30]="execution()!.status === 'error'"
|
||||
[class.border-red-200]="execution()!.status === 'error'"
|
||||
[class.dark:border-red-800]="execution()!.status === 'error'">
|
||||
|
||||
<!-- Running State -->
|
||||
<div *ngIf="execution()!.status === 'running'" class="flex items-center gap-3">
|
||||
<div class="animate-spin w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
Analyse en cours...
|
||||
</div>
|
||||
<div class="w-full h-2 bg-blue-100 dark:bg-blue-900/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-300 ease-out"
|
||||
[style.width.%]="execution()!.progress || 0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
{{ execution()!.progress || 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div *ngIf="execution()!.status === 'success'" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-green-900 dark:text-green-100 mb-1">
|
||||
Résumé ajouté avec succès !
|
||||
</div>
|
||||
<div *ngIf="execution()!.result?.data?.description" class="text-sm text-green-700 dark:text-green-300 bg-green-100/50 dark:bg-green-900/30 rounded-lg p-3 mt-2">
|
||||
{{ execution()!.result.data.description }}
|
||||
</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-2">
|
||||
Complété en {{ execution()!.result?.duration }}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div *ngIf="execution()!.status === 'error'" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium text-red-900 dark:text-red-100 mb-1">
|
||||
Erreur lors de l'exécution
|
||||
</div>
|
||||
<div class="text-sm text-red-700 dark:text-red-300">
|
||||
{{ execution()!.result?.error || 'Erreur inconnue' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Grid -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-main dark:text-white mb-4">Tâches disponibles</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
*ngFor="let task of gemini.availableTasks"
|
||||
type="button"
|
||||
[disabled]="!task.enabled || isRunning()"
|
||||
(click)="executeTask(task.id)"
|
||||
class="group relative p-5 rounded-xl border-2 transition-all duration-200 text-left"
|
||||
[class.opacity-50]="!task.enabled"
|
||||
[class.cursor-not-allowed]="!task.enabled || isRunning()"
|
||||
[class.border-border]="!task.enabled"
|
||||
[class.dark:border-gray-700]="!task.enabled"
|
||||
[class.border-purple-200]="task.enabled"
|
||||
[class.dark:border-purple-800]="task.enabled"
|
||||
[class.hover:border-purple-400]="task.enabled && !isRunning()"
|
||||
[class.dark:hover:border-purple-600]="task.enabled && !isRunning()"
|
||||
[class.hover:shadow-lg]="task.enabled && !isRunning()"
|
||||
[class.hover:scale-105]="task.enabled && !isRunning()"
|
||||
[class.bg-surface1/30]="!task.enabled"
|
||||
[class.dark:bg-card/30]="!task.enabled"
|
||||
[class.bg-gradient-to-br]="task.enabled"
|
||||
[class.from-purple-50/50]="task.enabled"
|
||||
[class.to-blue-50/50]="task.enabled"
|
||||
[class.dark:from-purple-950/20]="task.enabled"
|
||||
[class.dark:to-blue-950/20]="task.enabled">
|
||||
|
||||
<!-- Beta Badge -->
|
||||
<div *ngIf="task.beta" class="absolute top-3 right-3">
|
||||
<span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Icon -->
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="text-3xl flex-shrink-0" [class.grayscale]="!task.enabled">
|
||||
{{ task.icon }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Task Label -->
|
||||
<div class="font-semibold text-main dark:text-white mb-1 flex items-center gap-2">
|
||||
{{ task.label }}
|
||||
<svg *ngIf="isTaskRunning(task.id)" class="animate-spin w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Task Description -->
|
||||
<p class="text-sm text-muted leading-relaxed">
|
||||
{{ task.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hover Effect Arrow -->
|
||||
<div *ngIf="task.enabled && !isRunning()"
|
||||
class="absolute bottom-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<svg class="w-5 h-5 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Note Selected Warning -->
|
||||
<div *ngIf="!selectedNote" class="mt-6 p-4 rounded-xl bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Veuillez sélectionner une note pour utiliser les fonctionnalités IA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Footer -->
|
||||
<div class="pt-4 border-t border-border dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-xs text-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span>Service actif</span>
|
||||
</div>
|
||||
<div>
|
||||
Tâches exécutées: <span class="font-semibold">{{ gemini.tasksCount() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(75, 85, 99, 0.7);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class GeminiPanelComponent {
|
||||
readonly gemini = inject(GeminiService);
|
||||
|
||||
@Input() selectedNote: Note | null = null;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
// Signals pour la réactivité
|
||||
readonly execution = computed(() => this.gemini.currentExecution());
|
||||
readonly isRunning = computed(() => this.execution()?.status === 'running');
|
||||
|
||||
constructor() {
|
||||
// Auto-reset après 5 secondes en cas de succès
|
||||
effect(() => {
|
||||
const exec = this.execution();
|
||||
if (exec?.status === 'success') {
|
||||
setTimeout(() => {
|
||||
this.gemini.resetExecution();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (!this.isRunning()) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onBackdropClick(): void {
|
||||
if (!this.isRunning()) {
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute une tâche IA
|
||||
*/
|
||||
async executeTask(taskId: GeminiTaskType): Promise<void> {
|
||||
if (!this.selectedNote) {
|
||||
console.warn('[GeminiPanel] No note selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRunning()) {
|
||||
console.warn('[GeminiPanel] Task already running');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (taskId) {
|
||||
case 'generate-description':
|
||||
await this.gemini.generateDescription(this.selectedNote.id);
|
||||
break;
|
||||
case 'generate-tags':
|
||||
await this.gemini.generateTags(this.selectedNote.id);
|
||||
break;
|
||||
case 'detect-type':
|
||||
await this.gemini.detectType(this.selectedNote.id);
|
||||
break;
|
||||
case 'enrich-metadata':
|
||||
await this.gemini.enrichMetadata(this.selectedNote.id);
|
||||
break;
|
||||
case 'suggest-links':
|
||||
await this.gemini.suggestLinks(this.selectedNote.id);
|
||||
break;
|
||||
case 'extract-keywords':
|
||||
await this.gemini.extractKeywords(this.selectedNote.id);
|
||||
break;
|
||||
default:
|
||||
console.warn('[GeminiPanel] Unknown task:', taskId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GeminiPanel] Task execution failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une tâche spécifique est en cours d'exécution
|
||||
*/
|
||||
isTaskRunning(taskId: GeminiTaskType): boolean {
|
||||
const exec = this.execution();
|
||||
return exec?.taskId === taskId && exec?.status === 'running';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, computed, signal, effect, inject, ChangeDetectionStrategy, ViewChild, ElementRef, HostListener } from '@angular/core';
|
||||
import { input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
@@ -15,6 +15,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
||||
import { AIToolsService } from '../../services/ai-tools.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@@ -71,6 +72,13 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
title="Mode d'affichage">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
(click)="toggleSortOrder()"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors"
|
||||
title="Ordre">
|
||||
<svg *ngIf="state.sortOrder() === 'desc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<svg *ngIf="state.sortOrder() === 'asc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Active Kind Icon (to the right of the two icons) -->
|
||||
<span *ngIf="kindFilter() && kindFilter() !== 'all'" class="inline-flex items-center justify-center w-8 h-8 text-sm rounded-md bg-surface2/40 dark:bg-surface2/30" title="Filtre type actif">
|
||||
@@ -145,8 +153,12 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
[attr.data-note-path]="n.filePath"
|
||||
[attr.data-note-id]="n.id"
|
||||
tabindex="-1"
|
||||
(click)="openNote.emit(n.id)"
|
||||
(contextmenu)="openContextMenu($event, n)">
|
||||
(click)="onNoteClick($event, n)"
|
||||
(mousedown)="onNoteMouseDown($event, n)"
|
||||
(mouseup)="onNoteMouseUp($event)"
|
||||
(mouseleave)="onNoteMouseUp($event)"
|
||||
(contextmenu)="openContextMenu($event, n)"
|
||||
[class.selected-for-action]="isNoteSelected(n)">
|
||||
|
||||
<!-- Action Buttons (hover reveal) -->
|
||||
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
||||
@@ -199,6 +211,15 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Floating Selection Bar -->
|
||||
<div *ngIf="selectionMode()" class="selection-bar fixed bottom-4 left-1/2 -translate-x-1/2 bg-primary/95 dark:bg-primary/90 backdrop-blur-sm text-primary-foreground px-4 py-3 rounded-lg shadow-lg z-30 flex items-center gap-3 min-w-[300px] max-w-[500px]">
|
||||
<span class="font-semibold">{{ selectedNotes().length }} note(s) sélectionnée(s)</span>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" (click)="clearSelection()" class="px-2 py-1 rounded hover:bg-primary-foreground/10 transition-colors text-sm" title="Effacer la sélection">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Context Menu -->
|
||||
@@ -463,6 +484,58 @@ import { FileTypeDetectorService } from '../../../services/file-type-detector.se
|
||||
.note-row {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Multiple selection styles */
|
||||
.note-row.selected-for-action {
|
||||
background: color-mix(in oklab, var(--primary) 12%, var(--row-bg) 88%);
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 15%, transparent 85%);
|
||||
}
|
||||
|
||||
.note-row.selected-for-action .title {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.note-row.selected-for-action::before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Selection bar */
|
||||
.selection-bar {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translate(-50%, 100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* User select none during multi-selection to avoid text selection */
|
||||
:host-context(.selecting) {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListComponent {
|
||||
@@ -487,12 +560,14 @@ export class NotesListComponent {
|
||||
@Output() clearQuickLinkFilter = new EventEmitter<void>();
|
||||
@Output() noteCreated = new EventEmitter<string>();
|
||||
@Output() noteCreatedAndSelected = new EventEmitter<{ id: string; filePath: string }>();
|
||||
@Output() selectionChanged = new EventEmitter<Note[]>();
|
||||
|
||||
// Stores and services
|
||||
private store = inject(TagFilterStore);
|
||||
readonly state = inject(NotesListStateService);
|
||||
private noteCreationService = inject(NoteCreationService);
|
||||
readonly contextMenuService = inject(NoteContextMenuService);
|
||||
private aiTools = inject(AIToolsService);
|
||||
|
||||
// Local state
|
||||
private q = signal('');
|
||||
@@ -506,6 +581,12 @@ export class NotesListComponent {
|
||||
deleteWarningOpen = signal<boolean>(false);
|
||||
private deleteTarget: Note | null = null;
|
||||
|
||||
// Multiple selection state
|
||||
selectedNotes = signal<Note[]>([]);
|
||||
selectionMode = computed(() => this.selectedNotes().length > 0);
|
||||
private longPressTimer: any = null;
|
||||
private longPressThreshold = 500; // ms
|
||||
|
||||
openDeleteWarning(note: Note) {
|
||||
this.contextMenuService.close();
|
||||
this.deleteTarget = note;
|
||||
@@ -748,16 +829,24 @@ export class NotesListComponent {
|
||||
}
|
||||
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
const dir = this.state.sortOrder() === 'asc' ? 1 : -1;
|
||||
return [...list].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'title':
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
case 'created':
|
||||
return parseDate(b.createdAt) - parseDate(a.createdAt);
|
||||
case 'title': {
|
||||
const cmp = (a.title || '').localeCompare(b.title || '');
|
||||
return cmp * dir;
|
||||
}
|
||||
case 'created': {
|
||||
const da = parseDate(a.createdAt);
|
||||
const db = parseDate(b.createdAt);
|
||||
return (da - db) * dir;
|
||||
}
|
||||
case 'updated':
|
||||
default:
|
||||
return (b.mtime || parseDate(b.updatedAt) || parseDate(b.createdAt) || 0) -
|
||||
(a.mtime || parseDate(a.updatedAt) || parseDate(a.createdAt) || 0);
|
||||
default: {
|
||||
const va = (a.mtime || parseDate(a.updatedAt) || parseDate(a.createdAt) || 0);
|
||||
const vb = (b.mtime || parseDate(b.updatedAt) || parseDate(b.createdAt) || 0);
|
||||
return (va - vb) * dir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -789,6 +878,10 @@ export class NotesListComponent {
|
||||
this.viewModeMenuOpen.set(false);
|
||||
}
|
||||
|
||||
toggleSortOrder(): void {
|
||||
this.state.toggleSortOrder();
|
||||
}
|
||||
|
||||
getSortLabel(sort: SortBy): string {
|
||||
const labels: Record<SortBy, string> = {
|
||||
'title': 'Titre',
|
||||
@@ -955,4 +1048,117 @@ export class NotesListComponent {
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Multiple Selection Methods ============
|
||||
|
||||
/**
|
||||
* Gère le clic sur une note (avec support Ctrl+clic pour sélection multiple)
|
||||
*/
|
||||
onNoteClick(event: MouseEvent, note: Note): void {
|
||||
// Si Ctrl ou Cmd est pressé, toggle la sélection
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleSelection(note);
|
||||
} else {
|
||||
// Clic normal: ouvrir la note et clear la sélection
|
||||
if (this.selectionMode()) {
|
||||
this.clearSelection();
|
||||
}
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte le début d'un long press (mobile)
|
||||
*/
|
||||
onNoteMouseDown(event: MouseEvent, note: Note): void {
|
||||
// Sur mobile (touch), on détecte le long press
|
||||
if (event.button !== 0) return; // Seulement clic gauche
|
||||
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
// Long press détecté: entrer en mode sélection
|
||||
this.toggleSelection(note);
|
||||
this.longPressTimer = null;
|
||||
}, this.longPressThreshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule le timer du long press
|
||||
*/
|
||||
onNoteMouseUp(event: MouseEvent): void {
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle la sélection d'une note
|
||||
*/
|
||||
toggleSelection(note: Note): void {
|
||||
const current = this.selectedNotes();
|
||||
const index = current.findIndex(n => n.id === note.id);
|
||||
|
||||
if (index >= 0) {
|
||||
// Déjà sélectionnée: retirer
|
||||
const updated = [...current.slice(0, index), ...current.slice(index + 1)];
|
||||
this.selectedNotes.set(updated);
|
||||
this.selectionChanged.emit(updated);
|
||||
} else {
|
||||
// Pas sélectionnée: ajouter
|
||||
const updated = [...current, note];
|
||||
this.selectedNotes.set(updated);
|
||||
this.selectionChanged.emit(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une note est sélectionnée
|
||||
*/
|
||||
isNoteSelected(note: Note): boolean {
|
||||
return this.selectedNotes().some(n => n.id === note.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear toutes les sélections
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.selectedNotes.set([]);
|
||||
this.selectionChanged.emit([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne toutes les notes filtrées
|
||||
*/
|
||||
selectAll(): void {
|
||||
const all = this.filtered();
|
||||
this.selectedNotes.set(all);
|
||||
this.selectionChanged.emit(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccourci clavier pour gérer la sélection (Ctrl+A)
|
||||
*/
|
||||
@HostListener('document:keydown.control.a', ['$event'])
|
||||
@HostListener('document:keydown.meta.a', ['$event'])
|
||||
onSelectAllKeyboard(event: KeyboardEvent): void {
|
||||
// Seulement si le focus est dans notre composant
|
||||
const target = event.target as HTMLElement;
|
||||
if (target && target.closest('app-notes-list')) {
|
||||
event.preventDefault();
|
||||
this.selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccourci clavier Escape pour clear la sélection
|
||||
*/
|
||||
@HostListener('document:keydown.escape', ['$event'])
|
||||
onEscapeKeyboard(event: KeyboardEvent): void {
|
||||
if (this.selectionMode()) {
|
||||
event.preventDefault();
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, EventEmitter, Output, input, signal, computed, effect, inject, OnInit, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { Component, EventEmitter, Output, input, signal, computed, effect, inject, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { PaginationService, NoteMetadata } from '../../services/pagination.service';
|
||||
import type { Note } from '../../../types';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
||||
import { TagFilterStore } from '../../core/stores/tag-filter.store';
|
||||
@@ -10,6 +11,7 @@ import { FilterService } from '../../services/filter.service';
|
||||
import { NoteContextMenuService } from '../../services/note-context-menu.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
|
||||
import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service';
|
||||
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
|
||||
import { FilterBadgeComponent } from '../../components/filter-badge/filter-badge.component';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
@@ -54,7 +56,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
placeholder="Rechercher..."
|
||||
class="w-full rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
<!-- Action Buttons (Sort + View Mode) -->
|
||||
<!-- Action Buttons (Sort + View Mode + Order) -->
|
||||
<div class="action-buttons flex justify-between items-center">
|
||||
<div class="flex items-center gap-2 relative">
|
||||
<button type="button" (click)="toggleSortMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Trier par">
|
||||
@@ -63,6 +65,10 @@ import { takeUntil } from 'rxjs/operators';
|
||||
<button type="button" (click)="toggleViewModeMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Mode d'affichage">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</button>
|
||||
<button type="button" (click)="toggleSortOrder()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Ordre">
|
||||
<svg *ngIf="state.sortOrder() === 'desc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<svg *ngIf="state.sortOrder() === 'asc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
<div *ngIf="sortMenuOpen()" class="absolute top-full left-0 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
||||
@@ -100,7 +106,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
[ngStyle]="getNoteGradientStyleById(note.id)"
|
||||
[attr.data-note-id]="note.id"
|
||||
[class.active]="(selectedId() ?? selectedNoteId()) === note.id"
|
||||
(click)="selectNote(note)"
|
||||
[class.selected-for-action]="isSelected(note.id)"
|
||||
(click)="onRowClick($event, note)"
|
||||
(mousedown)="onRowMouseDown($event, note)"
|
||||
(mouseup)="onRowMouseUp($event)"
|
||||
(mouseleave)="onRowMouseUp($event)"
|
||||
(contextmenu)="openContextMenu($event, note.id)">
|
||||
|
||||
<!-- Action Buttons (hover reveal) -->
|
||||
@@ -161,6 +171,15 @@ import { takeUntil } from 'rxjs/operators';
|
||||
</ul>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
</div>
|
||||
|
||||
<!-- Floating Selection Bar -->
|
||||
<div *ngIf="selectionMode()" class="selection-bar bg-primary/95 dark:bg-primary/90 backdrop-blur-sm text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px] max-w-[520px]">
|
||||
<span class="font-semibold">{{ selectedCount() }} note(s) sélectionnée(s)</span>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" (click)="clearSelection()" class="px-2 py-1 rounded hover:bg-primary-foreground/10 transition-colors text-xs" title="Effacer la sélection">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note Context Menu -->
|
||||
@@ -299,6 +318,22 @@ import { takeUntil } from 'rxjs/operators';
|
||||
.action-btn.edit { color: var(--primary, #3b82f6); }
|
||||
.action-btn.delete { color: #dc2626; }
|
||||
:host-context(html.dark) .action-btn.delete { color: #ef4444; }
|
||||
|
||||
/* Multi-selection visuals */
|
||||
.note-row.selected-for-action {
|
||||
outline: 2px solid var(--primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 15%, transparent 85%);
|
||||
}
|
||||
.note-row.selected-for-action .title { font-weight: 600; color: var(--primary); }
|
||||
.note-row.selected-for-action::before {
|
||||
content: "✓";
|
||||
position: absolute; top: 8px; left: 8px; width: 20px; height: 20px;
|
||||
background: var(--primary); color: var(--primary-foreground);
|
||||
border-radius: 9999px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; z-index: 5;
|
||||
}
|
||||
|
||||
.selection-bar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 50; }
|
||||
`]
|
||||
})
|
||||
export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
@@ -310,11 +345,19 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
readonly filter = inject(FilterService);
|
||||
readonly contextMenu = inject(NoteContextMenuService);
|
||||
private editorState = inject(EditorStateService);
|
||||
private keyboard = inject(KeyboardShortcutsService);
|
||||
private destroy$ = new Subject<void>();
|
||||
private preservedOffset: number | null = null;
|
||||
private useUnifiedSync = true;
|
||||
private lastSyncKey = signal<string>('');
|
||||
|
||||
// Multi-selection state
|
||||
selectedIds = signal<Set<string>>(new Set());
|
||||
selectedCount = computed(() => this.selectedIds().size);
|
||||
selectionMode = computed(() => this.selectedIds().size > 0);
|
||||
private longPressTimer: any = null;
|
||||
private longPressThreshold = 500; // ms
|
||||
|
||||
// Header shows only kind badges (IMAGE, PDF, VIDEO, etc.)
|
||||
badgesKindOnly = computed(() => (this.filter.badges() || []).filter((b: any) => b?.type === 'kind'));
|
||||
|
||||
@@ -571,23 +614,28 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
items = Array.from(byPath.values());
|
||||
if (dbg) console.log('[List] final', { count: items.length });
|
||||
|
||||
// Sorting (title/created/updated) like old list
|
||||
// Sorting (title/created/updated) with asc/desc
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
const sortBy = this.state.sortBy();
|
||||
const dir = this.state.sortOrder() === 'asc' ? 1 : -1;
|
||||
items = [...items].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'title':
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
case 'created':
|
||||
return parseDate(b.createdAt) - parseDate(a.createdAt);
|
||||
case 'title': {
|
||||
const cmp = (a.title || '').localeCompare(b.title || '');
|
||||
return cmp * dir;
|
||||
}
|
||||
case 'created': {
|
||||
const da = parseDate(a.createdAt);
|
||||
const db = parseDate(b.createdAt);
|
||||
return (da - db) * dir;
|
||||
}
|
||||
case 'updated':
|
||||
default:
|
||||
{
|
||||
const mb = byId.get(b.id)?.mtime; const ma = byId.get(a.id)?.mtime;
|
||||
const ub = parseDate(b.updatedAt) || (mb ? Number(mb) : 0);
|
||||
const ua = parseDate(a.updatedAt) || (ma ? Number(ma) : 0);
|
||||
return ub - ua;
|
||||
}
|
||||
default: {
|
||||
const mb = byId.get(b.id)?.mtime; const ma = byId.get(a.id)?.mtime;
|
||||
const ub = parseDate(b.updatedAt) || (mb ? Number(mb) : 0);
|
||||
const ua = parseDate(a.updatedAt) || (ma ? Number(ma) : 0);
|
||||
return (ua - ub) * dir * -1; // normalize to (valueA - valueB) * dir
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -770,6 +818,80 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
this.openNote.emit(note.filePath || note.id);
|
||||
}
|
||||
|
||||
// --- Multi-selection handlers ---
|
||||
onRowClick(event: MouseEvent, meta: NoteMetadata): void {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
this.toggleSelection(meta.id);
|
||||
return;
|
||||
}
|
||||
if (this.selectionMode()) {
|
||||
// Exit selection mode and open the clicked note
|
||||
this.clearSelection();
|
||||
}
|
||||
this.selectNote(meta);
|
||||
}
|
||||
|
||||
onRowMouseDown(event: MouseEvent, meta: NoteMetadata): void {
|
||||
if (event.button !== 0) return; // only left click
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.toggleSelection(meta.id);
|
||||
this.longPressTimer = null;
|
||||
}, this.longPressThreshold);
|
||||
}
|
||||
|
||||
onRowMouseUp(_event: MouseEvent): void {
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSelection(id: string): void {
|
||||
const next = new Set(this.selectedIds());
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
this.selectedIds.set(next);
|
||||
this.pushSelectionToKeyboard();
|
||||
}
|
||||
|
||||
isSelected(id: string): boolean { return this.selectedIds().has(id); }
|
||||
|
||||
clearSelection(): void {
|
||||
if (this.selectedIds().size === 0) return;
|
||||
this.selectedIds.set(new Set());
|
||||
this.pushSelectionToKeyboard();
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
const all = new Set<string>((this.visibleNotes() || []).map(n => n.id));
|
||||
this.selectedIds.set(all);
|
||||
this.pushSelectionToKeyboard();
|
||||
}
|
||||
|
||||
private pushSelectionToKeyboard(): void {
|
||||
// Map selected IDs -> full Note objects
|
||||
const ids = Array.from(this.selectedIds());
|
||||
const notes: Note[] = [] as any;
|
||||
for (const id of ids) {
|
||||
const full = this.getFullNoteById(id) as Note | null;
|
||||
if (full) notes.push(full);
|
||||
}
|
||||
this.keyboard.setSelectedNotes(notes);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
@HostListener('document:keydown.control.a', ['$event'])
|
||||
@HostListener('document:keydown.meta.a', ['$event'])
|
||||
onSelectAllKeyboard(event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
this.selectAll();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape', ['$event'])
|
||||
onEscapeKeyboard(event: KeyboardEvent): void {
|
||||
if (this.selectionMode()) { event.preventDefault(); this.clearSelection(); }
|
||||
}
|
||||
|
||||
// Search
|
||||
onQuery(v: string) {
|
||||
this.q.set(v);
|
||||
@@ -904,7 +1026,14 @@ export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
||||
toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); }
|
||||
toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); }
|
||||
setSortBy(sort: SortBy): void { this.state.setSortBy(sort); this.sortMenuOpen.set(false); }
|
||||
setViewMode(mode: ViewMode): void { this.state.setViewMode(mode); this.viewModeMenuOpen.set(false); }
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.state.setViewMode(mode);
|
||||
this.viewModeMenuOpen.set(false);
|
||||
}
|
||||
|
||||
toggleSortOrder(): void {
|
||||
this.state.toggleSortOrder();
|
||||
}
|
||||
getSortLabel(sort: SortBy): string {
|
||||
const labels: Record<SortBy, string> = { title: 'Titre', created: 'Date création', updated: 'Date modification' };
|
||||
return labels[sort];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, computed, input, effect } from '@angular/core';
|
||||
import { Component, computed, input, effect, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
@@ -25,47 +25,88 @@ hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('yml', yaml);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
|
||||
// Configure highlight.js to avoid heavy auto-detection across many languages
|
||||
hljs.configure({
|
||||
languages: ['javascript','typescript','json','xml','html','css','bash','yaml','yml','sql','java','python']
|
||||
});
|
||||
|
||||
@Component({
|
||||
selector: 'app-code-renderer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
styles: [`
|
||||
:host { display:block; }
|
||||
.root { border: 1px solid var(--border); background: var(--card); border-radius: 1rem; overflow: hidden; }
|
||||
.header { display:flex; align-items:center; gap:.5rem; padding:.6rem .9rem; font-size:.8rem; color: var(--text-muted); border-bottom: 1px solid var(--border); background: linear-gradient(90deg, color-mix(in oklab, var(--card) 92%, black 8%), color-mix(in oklab, var(--card) 86%, black 14%)); }
|
||||
.title { display:inline-flex; align-items:center; gap:.5rem; font-weight:600; color: var(--text-main); }
|
||||
.root { width:100%; max-width: 1000px; min-width: 280px; margin: 0 auto; }
|
||||
.title svg { width: 16px; height: 16px; color: var(--accent, #9b87f5); }
|
||||
.lang { margin-left:auto; padding:.15rem .6rem; border:1px solid var(--border); border-radius:.5rem; font-weight:700; letter-spacing:.04em; color: var(--text-main); }
|
||||
pre { margin:0; padding: .9rem 1.1rem; font-family: var(--font-mono, ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace); font-size:.9rem; line-height:1.5; overflow:auto; }
|
||||
.icon-btn { margin-left:.4rem; display:inline-grid; place-items:center; width:28px; height:28px; border:1px solid var(--border); border-radius:.5rem; color: var(--text-main); background: transparent; cursor:pointer; }
|
||||
.icon-btn:hover { background: color-mix(in oklab, var(--card) 90%, black 10%); }
|
||||
pre { margin:0; padding: .9rem 1rem; font-family: var(--font-mono, ui-monospace, Menlo, Monaco, Consolas, 'Courier New', monospace); font-size:.9rem; line-height:1.5; overflow:auto; white-space: pre; }
|
||||
code { display:block; }
|
||||
.hljs { color: var(--text-main); }
|
||||
.wrap pre { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.wrap .code-block__body code { min-width: 0; }
|
||||
@media (min-width: 1280px) { .root { max-width: 1100px; } }
|
||||
@media (max-width: 640px) { .root { max-width: 100%; border-radius: .75rem; } }
|
||||
`],
|
||||
template: `
|
||||
<div class="root md-view animate-fadeIn">
|
||||
<div class="header">
|
||||
<span class="title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M16 18l6-6-6-6"/>
|
||||
<path d="M8 6L2 12l6 6"/>
|
||||
</svg>
|
||||
<span>code</span>
|
||||
</span>
|
||||
<span class="lang">{{ languageLabel() }}</span>
|
||||
<div class="root md-view animate-fadeIn" [class.wrap]="wrap()">
|
||||
<div class="code-block code-block--kind-code" [class.copied]="copied()">
|
||||
<div class="code-block__header">
|
||||
<div class="code-block__kind">
|
||||
<span class="code-block__kind-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M16 18l6-6-6-6"/>
|
||||
<path d="M8 6L2 12l6 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>code</span>
|
||||
</div>
|
||||
<div class="code-block__actions">
|
||||
<button type="button" class="code-block__language-badge" (click)="copyCode()" [attr.title]="'Copier le code'">{{ languageLabel() }}</button>
|
||||
<span class="code-block__copy-feedback">COPIÉ !</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="code-block__body">
|
||||
<pre><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<pre><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class CodeRendererComponent {
|
||||
path = input<string>('');
|
||||
content = input<string>('');
|
||||
wrap = signal(false);
|
||||
copied = signal(false);
|
||||
|
||||
fileName = computed(() => {
|
||||
const p = this.path() || '';
|
||||
return p.split('/').pop() || p.split('\\').pop() || 'code';
|
||||
});
|
||||
|
||||
languageFromPath = computed<string | null>(() => {
|
||||
const p = (this.path() || '').toLowerCase();
|
||||
if (!p) return null;
|
||||
if (p.endsWith('.js')) return 'javascript';
|
||||
if (p.endsWith('.mjs')) return 'javascript';
|
||||
if (p.endsWith('.cjs')) return 'javascript';
|
||||
if (p.endsWith('.ts')) return 'typescript';
|
||||
if (p.endsWith('.json')) return 'json';
|
||||
if (p.endsWith('.xml')) return 'xml';
|
||||
if (p.endsWith('.html')) return 'xml';
|
||||
if (p.endsWith('.css')) return 'css';
|
||||
if (p.endsWith('.yml') || p.endsWith('.yaml')) return 'yaml';
|
||||
if (p.endsWith('.sh') || p.endsWith('.bash')) return 'bash';
|
||||
if (p.endsWith('.ps1')) return 'powershell';
|
||||
if (p.endsWith('.sql')) return 'sql';
|
||||
if (p.endsWith('.java')) return 'java';
|
||||
if (p.endsWith('.py')) return 'python';
|
||||
return null;
|
||||
});
|
||||
|
||||
languageLabel = computed(() => {
|
||||
const explicit = this.languageFromPath();
|
||||
if (explicit) return explicit.toUpperCase();
|
||||
const src = this.content() || '';
|
||||
if (!src.trim()) return 'TEXT';
|
||||
try {
|
||||
@@ -78,11 +119,19 @@ export class CodeRendererComponent {
|
||||
|
||||
highlighted = computed(() => {
|
||||
const src = this.content() || '';
|
||||
if (!src.trim()) return '';
|
||||
const MAX_HL = 150_000; // Avoid freezing on very large files
|
||||
if (src.length > MAX_HL) {
|
||||
return this.escapeHtml(src);
|
||||
}
|
||||
const lang = this.languageFromPath();
|
||||
try {
|
||||
if (!src.trim()) { return ''; }
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
const res = hljs.highlight(src, { language: lang, ignoreIllegals: true });
|
||||
return res.value || this.escapeHtml(src);
|
||||
}
|
||||
const res = hljs.highlightAuto(src);
|
||||
const v = res.value;
|
||||
return v && v.length > 0 ? v : this.escapeHtml(src);
|
||||
return res.value || this.escapeHtml(src);
|
||||
} catch {
|
||||
return this.escapeHtml(src);
|
||||
}
|
||||
@@ -102,4 +151,18 @@ export class CodeRendererComponent {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
copyCode(): void {
|
||||
const src = this.content() || '';
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(src).then(() => {
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 900);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
toggleWrap(): void {
|
||||
this.wrap.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Integrations Section -->
|
||||
<section class="parameters-section">
|
||||
<h2 class="section-title">
|
||||
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v20M2 12h20"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
Integrations
|
||||
</h2>
|
||||
|
||||
<app-settings-integrations-gemini></app-settings-integrations-gemini>
|
||||
</section>
|
||||
|
||||
<!-- Vault Stats Section (Placeholder) -->
|
||||
<section class="parameters-section">
|
||||
<h2 class="section-title">
|
||||
|
||||
@@ -5,11 +5,12 @@ import { ThemeService, ThemeMode, ThemeId, Language } from '../../core/services/
|
||||
import { SettingsService } from '../../services/settings.service';
|
||||
import { ToastService } from '../../shared/toast/toast.service';
|
||||
import { FolderFilterService, FolderFilterConfig } from '../../services/folder-filter.service';
|
||||
import { SettingsIntegrationsGeminiComponent } from '../settings/integrations/settings-integrations-gemini.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-parameters',
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [CommonModule, FormsModule, SettingsIntegrationsGeminiComponent],
|
||||
templateUrl: './parameters.page.html',
|
||||
styleUrls: ['./parameters.page.css']
|
||||
})
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IntegrationsService } from '../../../services/integrations.service';
|
||||
import {
|
||||
GeminiStatus,
|
||||
GeminiStatusResponse,
|
||||
GEMINI_STATUS_LABELS,
|
||||
GEMINI_REASON_MESSAGES
|
||||
} from '../../../services/integrations.types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-integrations-gemini',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="space-y-6 p-6 bg-surface dark:bg-card rounded-xl border border-border dark:border-gray-700">
|
||||
<!-- Header avec actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
|
||||
<span class="text-2xl">🤖</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-main dark:text-white">Google Gemini API</h2>
|
||||
<p class="text-sm text-muted">Intelligence artificielle pour vos notes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-sm rounded-lg border border-border dark:border-gray-700 hover:bg-surface1 dark:hover:bg-main transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
(click)="refresh()"
|
||||
[disabled]="loading() || testing()">
|
||||
<span *ngIf="!loading()">🔄 Rafraîchir</span>
|
||||
<span *ngIf="loading()">⏳ Chargement...</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-sm rounded-lg bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
(click)="test()"
|
||||
[disabled]="testing() || loading() || status() === 'NOT_CONFIGURED'">
|
||||
<span *ngIf="!testing()">🧪 Tester la clé</span>
|
||||
<span *ngIf="testing()">⏳ Test en cours...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statut actuel -->
|
||||
<div class="flex items-center gap-4 p-4 rounded-lg bg-surface1 dark:bg-main border border-border dark:border-gray-700">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<span class="text-sm font-medium text-muted">Statut:</span>
|
||||
<span
|
||||
class="px-2.5 py-1 text-sm font-medium rounded-md flex items-center gap-1"
|
||||
[ngClass]="{
|
||||
'bg-red-500/15 text-red-400 ring-1 ring-red-500/30': status() === 'NOT_CONFIGURED' || status() === 'INVALID',
|
||||
'bg-amber-500/15 text-amber-400 ring-1 ring-amber-500/30': status() === 'CONFIGURED_UNVERIFIED',
|
||||
'bg-emerald-500/15 text-emerald-400 ring-1 ring-emerald-500/30': status() === 'WORKING',
|
||||
'bg-gray-500/15 text-gray-400 ring-1 ring-gray-500/30': !status()
|
||||
}">
|
||||
<span aria-hidden="true">{{ getStatusEmoji() }}</span>
|
||||
<span>{{ getStatusLabel() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted flex items-center gap-1">
|
||||
<span>Dernier test:</span>
|
||||
<span class="font-medium">{{ getLastCheckedDisplay() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message d'information selon le statut -->
|
||||
<div *ngIf="status() === 'NOT_CONFIGURED'" class="rounded-lg border-2 border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl">⚠️</span>
|
||||
<div class="flex-1 space-y-2">
|
||||
<h3 class="font-semibold text-amber-600 dark:text-amber-400">Configuration requise</h3>
|
||||
<p class="text-sm text-muted">
|
||||
La clé API Gemini n'est pas configurée sur le serveur.
|
||||
</p>
|
||||
<div class="mt-3 p-3 rounded bg-surface dark:bg-card border border-border dark:border-gray-700 text-xs font-mono">
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted"># Ajoutez cette ligne dans votre fichier .env</div>
|
||||
<div class="text-main dark:text-white">GEMINI_API_KEY=votre_clé_api_ici</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-2">
|
||||
Puis redémarrez le serveur et cliquez sur <strong>Rafraîchir</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="status() === 'CONFIGURED_UNVERIFIED'" class="rounded-lg border-2 border-blue-500/30 bg-blue-500/5 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl">ℹ️</span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-blue-600 dark:text-blue-400">Clé configurée</h3>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
La clé API est présente dans la configuration, mais n'a pas encore été testée.
|
||||
</p>
|
||||
<p class="text-sm text-muted mt-2">
|
||||
Cliquez sur <strong>Tester la clé</strong> pour vérifier qu'elle fonctionne.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="status() === 'WORKING'" class="rounded-lg border-2 border-emerald-500/30 bg-emerald-500/5 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl">✅</span>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-emerald-600 dark:text-emerald-400">Clé fonctionnelle</h3>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
La clé API Gemini est valide et fonctionne correctement.
|
||||
</p>
|
||||
<p class="text-sm text-muted mt-2">
|
||||
Vous pouvez utiliser les fonctionnalités IA dans le panneau Gemini 🤖.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="status() === 'INVALID'" class="rounded-lg border-2 border-red-500/30 bg-red-500/5 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-2xl">❌</span>
|
||||
<div class="flex-1 space-y-2">
|
||||
<h3 class="font-semibold text-red-600 dark:text-red-400">Erreur de configuration</h3>
|
||||
<p class="text-sm text-muted">
|
||||
{{ getReasonMessage() }}
|
||||
</p>
|
||||
<div *ngIf="details()?.httpCode" class="text-xs text-muted">
|
||||
Code HTTP: <span class="font-mono">{{ details()?.httpCode }}</span>
|
||||
</div>
|
||||
<div *ngIf="details()?.message" class="text-xs text-muted mt-1">
|
||||
{{ details()?.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Détails techniques (collapsible) -->
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm text-muted hover:text-main dark:hover:text-white transition-colors select-none">
|
||||
<span class="inline-block group-open:rotate-90 transition-transform">▶</span>
|
||||
Détails techniques
|
||||
</summary>
|
||||
<div class="mt-3 p-3 rounded-lg bg-surface1 dark:bg-main border border-border dark:border-gray-700 text-xs font-mono">
|
||||
<pre class="whitespace-pre-wrap text-muted">{{ getDetailsJson() }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Message de toast (erreur) -->
|
||||
<div
|
||||
*ngIf="errorMessage()"
|
||||
class="fixed bottom-4 right-4 max-w-md p-4 rounded-lg bg-red-500 text-white shadow-lg animate-slide-in-right z-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-xl">⚠️</span>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">Erreur</p>
|
||||
<p class="text-sm mt-1 opacity-90">{{ errorMessage() }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/80 hover:text-white transition-colors"
|
||||
(click)="clearError()">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SettingsIntegrationsGeminiComponent implements OnInit {
|
||||
private readonly integrationsService = inject(IntegrationsService);
|
||||
|
||||
// Signals pour l'état
|
||||
readonly loading = signal<boolean>(true);
|
||||
readonly testing = signal<boolean>(false);
|
||||
readonly status = signal<GeminiStatus | null>(null);
|
||||
readonly lastCheckedAt = signal<string | null>(null);
|
||||
readonly details = signal<GeminiStatusResponse['details'] | null>(null);
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit le statut depuis le serveur
|
||||
*/
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.clearError();
|
||||
|
||||
this.integrationsService.getGeminiStatus().subscribe({
|
||||
next: (response) => {
|
||||
this.status.set(response.status);
|
||||
this.lastCheckedAt.set(response.lastCheckedAt);
|
||||
this.details.set(response.details);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage.set(error.message || 'Impossible de récupérer le statut');
|
||||
// Auto-clear après 5 secondes
|
||||
setTimeout(() => this.clearError(), 5000);
|
||||
},
|
||||
complete: () => {
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute un test live de la clé API
|
||||
*/
|
||||
test(): void {
|
||||
if (this.status() === 'NOT_CONFIGURED') {
|
||||
this.errorMessage.set('Veuillez d\'abord configurer la clé API sur le serveur');
|
||||
setTimeout(() => this.clearError(), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
this.testing.set(true);
|
||||
this.clearError();
|
||||
|
||||
this.integrationsService.testGeminiKey().subscribe({
|
||||
next: (response) => {
|
||||
this.status.set(response.status);
|
||||
this.lastCheckedAt.set(response.lastCheckedAt);
|
||||
this.details.set(response.details);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage.set(error.message || 'Erreur lors du test de la clé');
|
||||
// Auto-clear après 5 secondes
|
||||
setTimeout(() => this.clearError(), 5000);
|
||||
},
|
||||
complete: () => {
|
||||
this.testing.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Efface le message d'erreur
|
||||
*/
|
||||
clearError(): void {
|
||||
this.errorMessage.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le label UI du statut actuel
|
||||
*/
|
||||
getStatusLabel(): string {
|
||||
const currentStatus = this.status();
|
||||
if (!currentStatus) return 'Chargement...';
|
||||
return GEMINI_STATUS_LABELS[currentStatus] || currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne un emoji indicateur pour le statut courant
|
||||
*/
|
||||
getStatusEmoji(): string {
|
||||
const currentStatus = this.status();
|
||||
if (!currentStatus) return '⏳';
|
||||
|
||||
switch (currentStatus) {
|
||||
case 'NOT_CONFIGURED':
|
||||
return '🔴';
|
||||
case 'INVALID':
|
||||
return '🔴';
|
||||
case 'CONFIGURED_UNVERIFIED':
|
||||
return '🟡';
|
||||
case 'WORKING':
|
||||
return '🟢';
|
||||
default:
|
||||
return '⚪️';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le message de la raison d'erreur
|
||||
*/
|
||||
getReasonMessage(): string {
|
||||
const detailsValue = this.details();
|
||||
if (!detailsValue) return 'Erreur inconnue';
|
||||
return GEMINI_REASON_MESSAGES[detailsValue.reason] || detailsValue.reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche la date du dernier test
|
||||
*/
|
||||
getLastCheckedDisplay(): string {
|
||||
const lastCheck = this.lastCheckedAt();
|
||||
if (!lastCheck) return 'Jamais';
|
||||
|
||||
const date = new Date(lastCheck);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant';
|
||||
if (diffMins < 60) return `Il y a ${diffMins} min`;
|
||||
if (diffMins < 1440) return `Il y a ${Math.floor(diffMins / 60)} h`;
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les détails en JSON formaté
|
||||
*/
|
||||
getDetailsJson(): string {
|
||||
return JSON.stringify({
|
||||
status: this.status(),
|
||||
lastCheckedAt: this.lastCheckedAt(),
|
||||
details: this.details()
|
||||
}, null, 2);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { VaultNode, TagInfo } from '../../../types';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explorer.component';
|
||||
import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-drawer',
|
||||
@@ -102,6 +103,31 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI Tools accordion -->
|
||||
<section class="border-b border-border dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
|
||||
(click)="open.ai = !open.ai">
|
||||
<span class="flex items-center gap-2">🤖 <span>AI Tools</span></span>
|
||||
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.ai">{{ open.ai ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.ai" class="px-2 py-2">
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li *ngFor="let tool of aiTools.tools">
|
||||
<button
|
||||
(click)="onAIToolClick(tool)"
|
||||
[disabled]="!tool.enabled"
|
||||
class="w-full text-left px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-all active:scale-[0.98] transform"
|
||||
[class.opacity-50]="!tool.enabled"
|
||||
[class.cursor-not-allowed]="!tool.enabled"
|
||||
[title]="tool.description">
|
||||
<span>{{ tool.icon }}</span>
|
||||
<span class="ml-2">{{ tool.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trash accordion -->
|
||||
<section class="border-b border-border dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
|
||||
@@ -157,6 +183,7 @@ export class AppSidebarDrawerComponent {
|
||||
mobileNav = inject(MobileNavService);
|
||||
env = environment;
|
||||
private vault = inject(VaultService);
|
||||
aiTools = inject(AIToolsService);
|
||||
|
||||
@Input() nodes: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@@ -172,8 +199,9 @@ export class AppSidebarDrawerComponent {
|
||||
@Output() testsExcalidrawSelected = new EventEmitter<void>();
|
||||
@Output() helpPageSelected = new EventEmitter<void>();
|
||||
@Output() aboutSelected = new EventEmitter<void>();
|
||||
@Output() aiToolSelected = new EventEmitter<string>();
|
||||
|
||||
open = { quick: true, folders: true, tags: false, trash: false, tests: true };
|
||||
open = { quick: true, folders: true, tags: false, ai: false, trash: false, tests: true };
|
||||
|
||||
onSelect(id: string) {
|
||||
this.noteSelected.emit(id);
|
||||
@@ -225,4 +253,10 @@ export class AppSidebarDrawerComponent {
|
||||
trashCount = () => this.vault.counts().trash;
|
||||
trashHasContent = () => (this.vault.trashTree() || []).length > 0;
|
||||
trackNoteId = (_: number, n: { id: string }) => n.id;
|
||||
|
||||
onAIToolClick(tool: AIToolItem): void {
|
||||
if (!tool.enabled) return;
|
||||
this.aiToolSelected.emit(tool.id);
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VaultService } from '../../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { SidebarStateService } from '../../services/sidebar-state.service';
|
||||
import { FilterService } from '../../services/filter.service';
|
||||
import { AIToolsService, type AIToolItem } from '../../services/ai-tools.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
@@ -146,6 +147,34 @@ import { FilterService } from '../../services/filter.service';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI Tools accordion -->
|
||||
<section class="border-b border-border dark:border-gray-800">
|
||||
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||||
(click)="toggleSection('ai')">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">{{ open.ai ? '▾' : '▸' }}</span>
|
||||
<span>🤖</span>
|
||||
<span>AI Tools</span>
|
||||
</span>
|
||||
</button>
|
||||
<div *ngIf="open.ai" class="px-2 py-2">
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
<li *ngFor="let tool of aiTools.tools">
|
||||
<button
|
||||
(click)="onAIToolClick(tool)"
|
||||
[disabled]="!tool.enabled"
|
||||
class="flex-1 w-full text-left px-2.5 py-1.5 rounded-lg transition-colors hover:bg-slate-500/10 dark:hover:bg-surface2/15 truncate"
|
||||
[class.opacity-50]="!tool.enabled"
|
||||
[class.cursor-not-allowed]="!tool.enabled"
|
||||
[title]="tool.description">
|
||||
<span>{{ tool.icon }}</span>
|
||||
<span class="ml-2">{{ tool.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trash accordion -->
|
||||
<section class="border-b border-border dark:border-gray-800">
|
||||
<button class="w-full flex items-center px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
|
||||
@@ -215,24 +244,26 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
@Output() testsExcalidrawSelected = new EventEmitter<void>();
|
||||
@Output() helpPageSelected = new EventEmitter<void>();
|
||||
@Output() aboutSelected = new EventEmitter<void>();
|
||||
@Output() aiToolSelected = new EventEmitter<string>();
|
||||
|
||||
env = environment;
|
||||
open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
open = { quick: true, folders: false, tags: false, ai: false, trash: false, tests: false };
|
||||
private vault = inject(VaultService);
|
||||
urlState = inject(UrlStateService);
|
||||
private sidebar = inject(SidebarStateService);
|
||||
filters = inject(FilterService);
|
||||
aiTools = inject(AIToolsService);
|
||||
@ViewChild('foldersExplorer') private foldersExplorer?: FileExplorerComponent;
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['forceOpenSection']) {
|
||||
const which = this.forceOpenSection;
|
||||
if (which === 'folders') {
|
||||
this.open = { quick: false, folders: true, tags: false, trash: false, tests: false };
|
||||
this.open = { quick: false, folders: true, tags: false, ai: false, trash: false, tests: false };
|
||||
} else if (which === 'tags') {
|
||||
this.open = { quick: false, folders: false, tags: true, trash: false, tests: false };
|
||||
this.open = { quick: false, folders: false, tags: true, ai: false, trash: false, tests: false };
|
||||
} else if (which === 'quick') {
|
||||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
this.open = { quick: true, folders: false, tags: false, ai: false, trash: false, tests: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,14 +272,14 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
|
||||
onHomeClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
this.open = { quick: true, folders: false, tags: false, ai: false, trash: false, tests: false };
|
||||
this.sidebar.open('quick');
|
||||
void this.urlState.setQuickWithMarkdown('all');
|
||||
}
|
||||
|
||||
onQuickLinksHeaderClick(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.open = { quick: true, folders: false, tags: false, trash: false, tests: false };
|
||||
this.open = { quick: true, folders: false, tags: false, ai: false, trash: false, tests: false };
|
||||
this.sidebar.open('quick');
|
||||
void this.urlState.setQuickWithMarkdown('all');
|
||||
}
|
||||
@@ -266,9 +297,9 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
trashHasContent = () => (this.vault.trashTree() || []).length > 0;
|
||||
trackNoteId = (_: number, n: { id: string }) => n.id;
|
||||
|
||||
toggleSection(which: 'quick' | 'folders' | 'tags'): void {
|
||||
toggleSection(which: 'quick' | 'folders' | 'tags' | 'ai'): void {
|
||||
// Open requested section, close others, and reset filters/search via SidebarStateService
|
||||
this.open = { quick: false, folders: false, tags: false, trash: false, tests: false };
|
||||
this.open = { quick: false, folders: false, tags: false, ai: false, trash: false, tests: false };
|
||||
(this.open as any)[which] = true;
|
||||
this.sidebar.open(which);
|
||||
}
|
||||
@@ -300,4 +331,9 @@ export class NimbusSidebarComponent implements OnChanges {
|
||||
? 'bg-primary/15 text-primary ring-1 ring-primary/40'
|
||||
: 'bg-surface1/50 text-muted hover:bg-surface1 dark:hover:bg-card';
|
||||
}
|
||||
|
||||
onAIToolClick(tool: AIToolItem): void {
|
||||
if (!tool.enabled) return;
|
||||
this.aiToolSelected.emit(tool.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,14 @@ interface TestResult {
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="tests-panel">
|
||||
<div class="tests-panel" [class.tests-panel--fullscreen]="isFullscreen()">
|
||||
<div class="panel-header">
|
||||
<h2>🧪 API Tests Panel</h2>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="test-btn test-btn--neutral" (click)="toggleFullscreen()">
|
||||
{{ isFullscreen() ? 'Quitter plein écran' : 'Plein écran' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="panel-description">
|
||||
Test all ObsiViewer APIs and validate functionality. Each test shows execution time and response details.
|
||||
</p>
|
||||
@@ -29,129 +34,75 @@ interface TestResult {
|
||||
<div class="panel-content">
|
||||
<!-- Test Sections -->
|
||||
<div class="test-sections">
|
||||
<!-- Health Check -->
|
||||
|
||||
<div class="test-section">
|
||||
<h3>🏥 Health Check</h3>
|
||||
<button type="button" class="test-btn test-btn--primary" (click)="runHealthCheck()" [disabled]="isRunning()">
|
||||
Run Health Check
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vault APIs -->
|
||||
<div class="test-section">
|
||||
<h3>📁 Vault APIs</h3>
|
||||
<div class="test-group">
|
||||
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultMetadata()" [disabled]="isRunning()">
|
||||
GET /api/vault/metadata
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--secondary" (click)="runVaultPaginated()" [disabled]="isRunning()">
|
||||
GET /api/vault/metadata/paginated
|
||||
</button>
|
||||
<h3>🔭 All APIs Explorer</h3>
|
||||
<div class="file-test-form" style="margin-bottom: 12px;">
|
||||
<div class="form-row">
|
||||
<label for="apiSearch">Search endpoint</label>
|
||||
<input id="apiSearch" type="text" class="form-input" [(ngModel)]="builderSearchTerm" placeholder="search by name or path..." />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="apiSelect">Endpoint</label>
|
||||
<select id="apiSelect" class="form-input" [ngModel]="selectedEndpointKey()" (ngModelChange)="onSelectEndpoint($event)">
|
||||
<option *ngFor="let ep of getFilteredEndpoints()" [value]="ep.key">{{ ep.group }} • {{ ep.label }} ({{ ep.method }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" *ngIf="currentEndpoint">
|
||||
<div class="text-xs text-muted">Path: <code>{{ currentEndpoint.path }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files APIs -->
|
||||
<div class="test-section">
|
||||
<h3>📄 Files APIs</h3>
|
||||
<div class="file-test-form">
|
||||
<div class="form-row">
|
||||
<label for="filePath">File Path:</label>
|
||||
<input
|
||||
id="filePath"
|
||||
type="text"
|
||||
[(ngModel)]="testFilePath"
|
||||
placeholder="e.g., vault/home.md"
|
||||
class="form-input"
|
||||
/>
|
||||
|
||||
<div class="file-test-form" *ngIf="currentEndpoint">
|
||||
<div class="form-row" *ngIf="currentEndpoint.pathParams?.length">
|
||||
<label>Path params</label>
|
||||
<div class="test-group">
|
||||
<ng-container *ngFor="let p of currentEndpoint.pathParams">
|
||||
<div>
|
||||
<input class="form-input" [placeholder]="p" [ngModel]="paramValues()[p] || ''" (ngModelChange)="updateParamValue(p, $event)" />
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="fileContent">Content:</label>
|
||||
<textarea
|
||||
id="fileContent"
|
||||
[(ngModel)]="testFileContent"
|
||||
placeholder="Test content for file operations..."
|
||||
rows="3"
|
||||
class="form-textarea"
|
||||
></textarea>
|
||||
|
||||
<div class="form-row" *ngIf="currentEndpoint.query?.length">
|
||||
<label>Query params</label>
|
||||
<div class="test-group">
|
||||
<ng-container *ngFor="let q of currentEndpoint.query">
|
||||
<div>
|
||||
<input class="form-input" [placeholder]="q.name + (q.required ? ' *' : '')" [ngModel]="paramValues()[q.name] || ''" (ngModelChange)="updateParamValue(q.name, $event)" />
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" *ngIf="currentEndpoint.headersSupported">
|
||||
<label>Headers (JSON)</label>
|
||||
<textarea class="form-textarea" rows="3" [(ngModel)]="headersText"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row" *ngIf="currentEndpoint.key === 'files.putBlob'">
|
||||
<label>Upload file (PNG/SVG)</label>
|
||||
<input type="file" (change)="onFileChosen($event)" />
|
||||
<div class="text-xs text-muted" *ngIf="selectedFileName">Selected: {{ selectedFileName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" *ngIf="showBodyEditor()">
|
||||
<label>Body {{ currentEndpoint.contentType ? '(' + currentEndpoint.contentType + ')' : '' }}</label>
|
||||
<textarea class="form-textarea" rows="6" [(ngModel)]="bodyText"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="test-group">
|
||||
<button type="button" class="test-btn test-btn--info" (click)="runFileRead()" [disabled]="isRunning() || !testFilePath().trim()">
|
||||
GET /api/files (Read)
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--warning" (click)="runFileWrite()" [disabled]="isRunning() || !testFilePath().trim() || !testFileContent().trim()">
|
||||
PUT /api/files (Write)
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--danger" (click)="runFileDelete()" [disabled]="isRunning() || !testFilePath().trim()">
|
||||
DELETE /api/files (Delete)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State Management -->
|
||||
<div class="test-section">
|
||||
<h3>🏷️ State Management</h3>
|
||||
<div class="form-row">
|
||||
<label for="stateNoteId">Note ID:</label>
|
||||
<input
|
||||
id="stateNoteId"
|
||||
type="text"
|
||||
[(ngModel)]="testNoteId"
|
||||
placeholder="e.g., home"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="test-group">
|
||||
<button type="button" class="test-btn test-btn--accent" (click)="runTogglePublish()" [disabled]="isRunning() || !testNoteId().trim()">
|
||||
Toggle Publish
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--accent" (click)="runToggleFavorite()" [disabled]="isRunning() || !testNoteId().trim()">
|
||||
Toggle Favorite
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--accent" (click)="runToggleDraft()" [disabled]="isRunning() || !testNoteId().trim()">
|
||||
Toggle Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSE Events -->
|
||||
<div class="test-section">
|
||||
<h3>📡 SSE Events</h3>
|
||||
<div class="test-group">
|
||||
<button type="button" class="test-btn test-btn--success" (click)="runSSETest()" [disabled]="isRunning()">
|
||||
Test SSE Connection
|
||||
</button>
|
||||
<button type="button" class="test-btn test-btn--info" (click)="runSimulateEvent()" [disabled]="isRunning()">
|
||||
Simulate Event
|
||||
</button>
|
||||
</div>
|
||||
<div class="sse-events" *ngIf="sseEvents().length > 0">
|
||||
<h4>Recent Events:</h4>
|
||||
<div class="events-list">
|
||||
<div *ngFor="let event of sseEvents()" class="event-item">
|
||||
<span class="event-type">{{ event.type }}</span>
|
||||
<span class="event-data">{{ event.data | json }}</span>
|
||||
<span class="event-time">{{ event.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<button type="button" class="test-btn test-btn--primary" (click)="runDynamicRequest()" [disabled]="isRunning()">Run Request</button>
|
||||
<div class="text-xs text-muted" style="align-self:center;">
|
||||
<span>Preview:</span>
|
||||
<code>{{ buildPreviewUrl() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging -->
|
||||
<div class="test-section">
|
||||
<h3>📝 Logging</h3>
|
||||
<button type="button" class="test-btn test-btn--neutral" (click)="runLogTest()" [disabled]="isRunning()">
|
||||
Send Test Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations -->
|
||||
<div class="test-section">
|
||||
<h3>🔄 Batch Operations</h3>
|
||||
<button type="button" class="test-btn test-btn--primary" (click)="runBatchTest()" [disabled]="isRunning()">
|
||||
Run Full Test Suite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Panel -->
|
||||
@@ -202,6 +153,12 @@ interface TestResult {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-header .header-actions {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 24px;
|
||||
@@ -220,6 +177,8 @@ interface TestResult {
|
||||
.panel-content {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
grid-template-columns: minmax(0, 1fr) 520px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.test-sections {
|
||||
@@ -363,6 +322,11 @@ interface TestResult {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
max-height: calc(100vh - 60px);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
@@ -400,8 +364,9 @@ interface TestResult {
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
@@ -562,6 +527,38 @@ interface TestResult {
|
||||
.event-time {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.panel-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.results-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.results-list {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.tests-panel--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--bg);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
padding: 12px 12px 12px 12px;
|
||||
}
|
||||
|
||||
.tests-panel--fullscreen .panel-content {
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
.tests-panel--fullscreen .test-sections {
|
||||
overflow: auto;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TestsPanelComponent {
|
||||
@@ -579,6 +576,234 @@ export class TestsPanelComponent {
|
||||
|
||||
// SSE connection
|
||||
private sseConnection: EventSource | null = null;
|
||||
isFullscreen = signal(false);
|
||||
|
||||
// ---------- API Explorer (dynamic) ----------
|
||||
builderSearchTerm = '';
|
||||
selectedEndpointKey = signal<string>('health.get');
|
||||
paramValues = signal<Record<string, string>>({});
|
||||
bodyText = signal<string>('{}');
|
||||
headersText = signal<string>('{}');
|
||||
selectedFile: File | null = null;
|
||||
selectedFileName = '';
|
||||
|
||||
endpoints: Array<{
|
||||
key: string;
|
||||
group: string;
|
||||
label: string;
|
||||
method: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'SSE';
|
||||
path: string; // may include {param}
|
||||
pathParams?: string[];
|
||||
query?: Array<{ name: string; required?: boolean }>;
|
||||
contentType?: 'json'|'text'|'binary';
|
||||
responseType?: 'json'|'text';
|
||||
headersSupported?: boolean;
|
||||
sampleBody?: any;
|
||||
}> = [
|
||||
{ key: 'health.get', group: 'System', label: 'Health', method: 'GET', path: '/api/health' },
|
||||
{ key: 'perf.get', group: 'System', label: 'Performance', method: 'GET', path: '/__perf' },
|
||||
{ key: 'settings.get', group: 'System', label: 'Get Settings', method: 'GET', path: '/api/settings' },
|
||||
{ key: 'settings.put', group: 'System', label: 'Update Settings', method: 'PUT', path: '/api/settings', contentType: 'json', sampleBody: { enableBackups: true } },
|
||||
{ key: 'events.sse', group: 'Events', label: 'Vault Events (SSE)', method: 'SSE', path: '/api/vault/events' },
|
||||
|
||||
{ key: 'vault.notesRaw', group: 'Vault', label: 'Load Vault Notes (raw)', method: 'GET', path: '/api/vault' },
|
||||
{ key: 'vault.metadata', group: 'Vault', label: 'Metadata', method: 'GET', path: '/api/vault/metadata' },
|
||||
{ key: 'vault.metadataPaginated', group: 'Vault', label: 'Metadata (paginated)', method: 'GET', path: '/api/vault/metadata/paginated', query: [
|
||||
{ name: 'limit' }, { name: 'cursor' }, { name: 'search' }, { name: 'folder' }
|
||||
]},
|
||||
|
||||
{ key: 'files.list', group: 'Files', label: 'List from Meili', method: 'GET', path: '/api/files/list' },
|
||||
{ key: 'files.metadata', group: 'Files', label: 'Metadata (mixed)', method: 'GET', path: '/api/files/metadata', query: [ { name: 'source' } ] },
|
||||
{ key: 'files.byDate', group: 'Files', label: 'By Date', method: 'GET', path: '/api/files/by-date', query: [ { name: 'date', required: true } ] },
|
||||
{ key: 'files.byDateRange', group: 'Files', label: 'By Date Range', method: 'GET', path: '/api/files/by-date-range', query: [ { name: 'start', required: true }, { name: 'end' } ] },
|
||||
{ key: 'files.get', group: 'Files', label: 'Read File', method: 'GET', path: '/api/files', query: [ { name: 'path', required: true } ], responseType: 'text' },
|
||||
{ key: 'files.put', group: 'Files', label: 'Write File', method: 'PUT', path: '/api/files', query: [ { name: 'path', required: true } ], contentType: 'text', sampleBody: '# Title\n\nContent...' },
|
||||
{ key: 'files.putBlob', group: 'Files', label: 'Write Binary Sidecar', method: 'PUT', path: '/api/files/blob', query: [ { name: 'path', required: true } ], contentType: 'binary' },
|
||||
{ key: 'files.rename', group: 'Files', label: 'Rename File', method: 'PUT', path: '/api/files/rename', contentType: 'json', sampleBody: { oldPath: 'folder/old.md', newName: 'new.md' } },
|
||||
|
||||
{ key: 'notes.create', group: 'Notes', label: 'Create Note', method: 'POST', path: '/api/vault/notes', contentType: 'json', sampleBody: { fileName: 'Nouvelle note.md', folderPath: '', frontmatter: { title: 'Nouvelle note' }, content: '# Nouvelle note' } },
|
||||
{ key: 'notes.updateFrontmatter', group: 'Notes', label: 'Update Frontmatter', method: 'PATCH', path: '/api/vault/notes/{id}', pathParams: ['id'], contentType: 'json', sampleBody: { frontmatter: { title: 'Updated' } } },
|
||||
{ key: 'notes.delete', group: 'Notes', label: 'Delete (to .trash)', method: 'DELETE', path: '/api/vault/notes/{id}', pathParams: ['id'] },
|
||||
{ key: 'notes.move', group: 'Notes', label: 'Move Note', method: 'POST', path: '/api/vault/notes/move', contentType: 'json', sampleBody: { notePath: 'folder/a.md', newFolderPath: 'folder2' } },
|
||||
{ key: 'notes.tags', group: 'Notes', label: 'Update Tags', method: 'PUT', path: '/api/notes/{idOrPath}/tags', pathParams: ['idOrPath'], contentType: 'json', sampleBody: { tags: ['home','docs'] } },
|
||||
|
||||
{ key: 'folders.list', group: 'Folders', label: 'List Folders', method: 'GET', path: '/api/folders/list' },
|
||||
{ key: 'folders.create', group: 'Folders', label: 'Create Folder', method: 'POST', path: '/api/folders', contentType: 'json', sampleBody: { path: 'new-folder' } },
|
||||
{ key: 'folders.rename', group: 'Folders', label: 'Rename Folder', method: 'PUT', path: '/api/folders/rename', contentType: 'json', sampleBody: { oldPath: 'old-folder', newName: 'renamed-folder' } },
|
||||
{ key: 'folders.delete', group: 'Folders', label: 'Delete Folder', method: 'DELETE', path: '/api/folders', query: [ { name: 'path', required: true } ] },
|
||||
{ key: 'folders.duplicate', group: 'Folders', label: 'Duplicate Folder', method: 'POST', path: '/api/folders/duplicate', contentType: 'json', sampleBody: { sourcePath: 'src', destinationPath: 'dst' } },
|
||||
{ key: 'folders.deletePages', group: 'Folders', label: 'Delete Pages in Folder', method: 'DELETE', path: '/api/folders/pages', query: [ { name: 'path', required: true } ] },
|
||||
|
||||
{ key: 'attachments.resolve', group: 'Attachments', label: 'Resolve Attachment', method: 'GET', path: '/api/attachments/resolve', query: [ { name: 'name', required: true }, { name: 'note' }, { name: 'base' } ] },
|
||||
|
||||
{ key: 'search.query', group: 'Search', label: 'Meili Search', method: 'GET', path: '/api/search', query: [ { name: 'q', required: true }, { name: 'limit' }, { name: 'offset' }, { name: 'sort' }, { name: 'highlight' } ] },
|
||||
{ key: 'meili.reindex', group: 'Search', label: 'Manual Reindex', method: 'POST', path: '/api/reindex' },
|
||||
|
||||
{ key: 'graph.get', group: 'Config', label: 'Get Graph', method: 'GET', path: '/api/vault/graph' },
|
||||
{ key: 'graph.put', group: 'Config', label: 'Update Graph', method: 'PUT', path: '/api/vault/graph', headersSupported: true, contentType: 'json', sampleBody: { search: '', showTags: false } },
|
||||
{ key: 'bookmarks.get', group: 'Config', label: 'Get Bookmarks', method: 'GET', path: '/api/vault/bookmarks' },
|
||||
{ key: 'bookmarks.put', group: 'Config', label: 'Update Bookmarks', method: 'PUT', path: '/api/vault/bookmarks', headersSupported: true, contentType: 'json', sampleBody: { items: [] } },
|
||||
|
||||
{ key: 'quicklinks.counts', group: 'Quick Links', label: 'Counts', method: 'GET', path: '/api/quick-links/counts' },
|
||||
|
||||
{ key: 'logs.single', group: 'Logs', label: 'Post Log', method: 'POST', path: '/api/log', contentType: 'json', sampleBody: { event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } } },
|
||||
{ key: 'logs.batch', group: 'Logs', label: 'Post Log (alt)', method: 'POST', path: '/api/logs', contentType: 'json', sampleBody: { source: 'frontend', level: 'info', message: 'hello', data: { any: true } } },
|
||||
];
|
||||
|
||||
get currentEndpoint() {
|
||||
const key = this.selectedEndpointKey();
|
||||
return this.endpoints.find(e => e.key === key) || null;
|
||||
}
|
||||
|
||||
getFilteredEndpoints() {
|
||||
const q = (this.builderSearchTerm || '').toLowerCase();
|
||||
const list = this.endpoints.slice();
|
||||
if (!q) return list;
|
||||
return list.filter(e =>
|
||||
e.key.toLowerCase().includes(q) ||
|
||||
e.label.toLowerCase().includes(q) ||
|
||||
e.group.toLowerCase().includes(q) ||
|
||||
e.path.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
onSelectEndpoint(key: string): void {
|
||||
this.selectedEndpointKey.set(key);
|
||||
const ep = this.currentEndpoint;
|
||||
const initParams: Record<string, string> = {};
|
||||
(ep?.pathParams || []).forEach(p => initParams[p] = '');
|
||||
(ep?.query || []).forEach(q => { if (q.required) initParams[q.name] = ''; });
|
||||
this.paramValues.set(initParams);
|
||||
this.headersText.set(ep?.headersSupported ? '{\n "If-Match": "rev-or-etag"\n}' : '{}');
|
||||
if (ep?.contentType === 'json' && ep.sampleBody !== undefined) {
|
||||
this.bodyText.set(JSON.stringify(ep.sampleBody, null, 2));
|
||||
} else if (ep?.contentType === 'text' && typeof ep.sampleBody === 'string') {
|
||||
this.bodyText.set(String(ep.sampleBody));
|
||||
} else {
|
||||
this.bodyText.set(ep?.contentType === 'json' ? '{}' : '');
|
||||
}
|
||||
this.selectedFile = null;
|
||||
this.selectedFileName = '';
|
||||
}
|
||||
|
||||
updateParamValue(name: string, value: string): void {
|
||||
this.paramValues.update(map => ({ ...map, [name]: value }));
|
||||
}
|
||||
|
||||
onFileChosen(evt: Event): void {
|
||||
const input = evt.target as HTMLInputElement;
|
||||
const file = input?.files?.[0] || null;
|
||||
this.selectedFile = file;
|
||||
this.selectedFileName = file ? `${file.name} (${file.type || 'application/octet-stream'}, ${file.size} bytes)` : '';
|
||||
}
|
||||
|
||||
showBodyEditor(): boolean {
|
||||
const ep = this.currentEndpoint;
|
||||
if (!ep) return false;
|
||||
if (ep.contentType === 'binary') return false;
|
||||
return ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH';
|
||||
}
|
||||
|
||||
buildPreviewUrl(): string {
|
||||
const ep = this.currentEndpoint;
|
||||
if (!ep) return '';
|
||||
const path = this.replacePathParams(ep.path, this.paramValues());
|
||||
const qs = this.buildQueryString(ep.query, this.paramValues());
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
private replacePathParams(path: string, params: Record<string, string>): string {
|
||||
return path.replace(/\{(\w+)\}/g, (_m, p1) => encodeURIComponent(params[p1] || ''));
|
||||
}
|
||||
|
||||
private buildQueryString(query: Array<{ name: string; required?: boolean }> | undefined, params: Record<string, string>): string {
|
||||
if (!query || query.length === 0) return '';
|
||||
const sp = new URLSearchParams();
|
||||
for (const q of query) {
|
||||
const v = params[q.name];
|
||||
if (v !== undefined && v !== '') sp.append(q.name, v);
|
||||
}
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
async runDynamicRequest(): Promise<void> {
|
||||
const ep = this.currentEndpoint;
|
||||
if (!ep) return;
|
||||
|
||||
if (ep.method === 'SSE' || ep.path === '/api/vault/events') {
|
||||
this.runSSETest();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required params
|
||||
for (const p of (ep.pathParams || [])) {
|
||||
if (!this.paramValues()[p]?.trim()) {
|
||||
this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing path param: ${p}`, timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const q of (ep.query || [])) {
|
||||
if (q.required && !this.paramValues()[q.name]?.trim()) {
|
||||
this.addResult({ endpoint: ep.path, method: ep.method, status: 'error', error: `Missing query param: ${q.name}`, timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const urlPath = this.replacePathParams(ep.path, this.paramValues());
|
||||
const qs = this.buildQueryString(ep.query, this.paramValues());
|
||||
const fullUrl = qs ? `${urlPath}?${qs}` : urlPath;
|
||||
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (this.headersText().trim() && this.headersText().trim() !== '{}') {
|
||||
try { headers = JSON.parse(this.headersText()); } catch (e: any) {
|
||||
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid headers JSON: ${e.message || e}`, timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ep.contentType === 'binary') {
|
||||
if (!this.selectedFile) {
|
||||
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: 'Please choose a file to upload', timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
await this.runTest(ep.method, fullUrl, async () => {
|
||||
return this.http.put(fullUrl, this.selectedFile as any, {
|
||||
headers: { 'Content-Type': (this.selectedFile as File).type || 'application/octet-stream', ...(headers || {}) }
|
||||
}).toPromise();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare body
|
||||
let body: any = undefined;
|
||||
let contentTypeHeader: Record<string, string> = {};
|
||||
if (ep.method === 'POST' || ep.method === 'PUT' || ep.method === 'PATCH') {
|
||||
if (ep.contentType === 'json') {
|
||||
const txt = this.bodyText();
|
||||
try { body = txt ? JSON.parse(txt) : {}; } catch (e: any) {
|
||||
this.addResult({ endpoint: fullUrl, method: ep.method, status: 'error', error: `Invalid JSON body: ${e.message || e}`, timestamp: Date.now() });
|
||||
return;
|
||||
}
|
||||
contentTypeHeader = { 'Content-Type': 'application/json; charset=utf-8' };
|
||||
} else if (ep.contentType === 'text') {
|
||||
body = this.bodyText();
|
||||
contentTypeHeader = { 'Content-Type': 'text/markdown; charset=utf-8' };
|
||||
}
|
||||
}
|
||||
|
||||
const responseType = ep.responseType === 'text' ? 'text' : 'json';
|
||||
await this.runTest(ep.method, fullUrl, async () => {
|
||||
return this.http.request(ep.method, fullUrl, {
|
||||
body,
|
||||
headers: { ...(contentTypeHeader || {}), ...(headers || {}) },
|
||||
responseType: responseType as any
|
||||
}).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
this.isFullscreen.update(v => !v);
|
||||
}
|
||||
|
||||
async runHealthCheck(): Promise<void> {
|
||||
await this.runTest('GET', '/api/health', async () => {
|
||||
|
||||
@@ -26,11 +26,14 @@ import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal
|
||||
import { NoteInfoModalService } from '../../services/note-info-modal.service';
|
||||
import { InPageSearchService } from '../../shared/search/in-page-search.service';
|
||||
import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search-overlay.component';
|
||||
import { GeminiPanelComponent } from '../../features/gemini/gemini-panel.component';
|
||||
import { AIToolsService } from '../../services/ai-tools.service';
|
||||
import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-nimbus-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, PaginatedNotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, TestExcalidrawPageComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent, GeminiPanelComponent],
|
||||
template: `
|
||||
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
|
||||
|
||||
@@ -84,6 +87,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
(helpPageSelected)="onHelpPageSelected()"
|
||||
(aboutSelected)="onAboutSelected()"
|
||||
(noteCreated)="onNoteCreated($event)"
|
||||
(aiToolSelected)="onAIToolSelected($event)"
|
||||
/>
|
||||
</aside>
|
||||
</ng-container>
|
||||
@@ -94,6 +98,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('quick')" (mouseleave)="scheduleCloseFlyout()" title="Quick Links">▦</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('folders')" (mouseleave)="scheduleCloseFlyout()" title="Folders">📁</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('tags')" (mouseleave)="scheduleCloseFlyout()" title="Tags">🏷️</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('ai')" (mouseleave)="scheduleCloseFlyout()" title="AI Tools">🤖</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('trash')" (mouseleave)="scheduleCloseFlyout()" title="Trash">🗑️</button>
|
||||
<div class="h-px w-8 bg-border dark:bg-gray-800 my-1"></div>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('tests')" (mouseleave)="scheduleCloseFlyout()" title="Tests">🧪</button>
|
||||
@@ -104,16 +109,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
<!-- Flyouts -->
|
||||
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-card dark:bg-main border-r border-border dark:border-gray-800 shadow-xl z-50" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
|
||||
<div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800">
|
||||
<div class="text-sm font-semibold">{{
|
||||
f === 'quick' ? 'Quick Links' :
|
||||
(f === 'folders' ? 'Folders' :
|
||||
(f === 'tags' ? 'Tags' :
|
||||
(f === 'trash' ? 'Trash' :
|
||||
(f === 'help' ? 'Help' :
|
||||
(f === 'about' ? 'About' :
|
||||
(f === 'tests' ? 'Tests' :
|
||||
(f === 'playground' ? 'Playground' : '')))))))
|
||||
}}</div>
|
||||
<div class="text-sm font-semibold">{{ flyoutTitle(f) }}</div>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (click)="hoveredFlyout=null">✕</button>
|
||||
</div>
|
||||
<div class="h-[calc(100%-3rem)] overflow-y-auto" appScrollableOverlay>
|
||||
@@ -129,6 +125,17 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div *ngSwitchCase="'ai'" class="p-2">
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
<li *ngFor="let tool of aiTools.tools">
|
||||
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-surface1 dark:hover:bg-card truncate"
|
||||
[disabled]="!tool.enabled" [class.opacity-50]="!tool.enabled" (click)="onAIToolSelected(tool.id)">
|
||||
<span>{{ tool.icon }}</span>
|
||||
<span class="ml-2">{{ tool.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div *ngSwitchCase="'trash'" class="p-2">
|
||||
<ng-container *ngIf="(vault.trashTree() || []).length > 0; else emptyTrash">
|
||||
<app-file-explorer
|
||||
@@ -296,6 +303,7 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
(testsExcalidrawSelected)="onTestsExcalidrawSelected()"
|
||||
(helpPageSelected)="onHelpPageSelected()"
|
||||
(aboutSelected)="onAboutSelected()"
|
||||
(aiToolSelected)="onAIToolSelected($event)"
|
||||
></app-sidebar-drawer>
|
||||
|
||||
@if (mobileNav.activeTab() === 'list') {
|
||||
@@ -355,6 +363,9 @@ import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search
|
||||
|
||||
<!-- Note Info Modal -->
|
||||
<app-note-info-modal *ngIf="noteInfo.visible()" [note]="noteInfo.note()!" (close)="noteInfo.close()"></app-note-info-modal>
|
||||
|
||||
<!-- Gemini Panel -->
|
||||
<app-gemini-panel *ngIf="showGeminiPanel" [selectedNote]="selectedNote" (close)="showGeminiPanel = false"></app-gemini-panel>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -367,9 +378,12 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
filters = inject(FilterService);
|
||||
noteInfo = inject(NoteInfoModalService);
|
||||
inPageSearch = inject(InPageSearchService);
|
||||
aiTools = inject(AIToolsService);
|
||||
keyboard = inject(KeyboardShortcutsService);
|
||||
|
||||
noteFullScreen = false;
|
||||
showAboutPanel = false;
|
||||
showGeminiPanel = false;
|
||||
|
||||
@Input() vaultName = '';
|
||||
@Input() effectiveFileTree: VaultNode[] = [];
|
||||
@@ -409,7 +423,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
|
||||
folderFilter: string | null = null;
|
||||
listQuery: string = '';
|
||||
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null;
|
||||
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null = null;
|
||||
private flyoutCloseTimer: any = null;
|
||||
tagFilter: string | null = null;
|
||||
quickLinkFilter: 'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null = null;
|
||||
@@ -734,6 +748,37 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
this.showAboutPanel = true;
|
||||
}
|
||||
|
||||
onGeminiPanelOpen(): void {
|
||||
this.showGeminiPanel = true;
|
||||
this.scheduleCloseFlyout(0); // Fermer les flyouts
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler when an AI tool is selected from sidebars/flyouts.
|
||||
* Do not change focus of the notes list; just execute and log.
|
||||
*/
|
||||
async onAIToolSelected(actionId: string): Promise<void> {
|
||||
try {
|
||||
// Get selected notes from keyboard service; fallback to current selected note
|
||||
let notes = this.keyboard.selectedNotes();
|
||||
if (!notes || notes.length === 0) {
|
||||
if (this.selectedNote) notes = [this.selectedNote]; else notes = [];
|
||||
}
|
||||
console.log('[AI Tools] Action:', actionId, 'Notes:', notes.map(n => n.title));
|
||||
if (notes.length === 0) {
|
||||
console.warn('[AI Tools] No notes selected');
|
||||
return;
|
||||
}
|
||||
const result = await this.aiTools.executeAction(actionId as any, notes as any);
|
||||
console.log('[AI Tools] Result:', result);
|
||||
} catch (e) {
|
||||
console.error('[AI Tools] Execution error:', e);
|
||||
} finally {
|
||||
// Keep list focus; do not switch tabs or change URL
|
||||
this.scheduleCloseFlyout(150);
|
||||
}
|
||||
}
|
||||
|
||||
onNoteCreated(noteId: string) {
|
||||
this.noteCreated.emit(noteId);
|
||||
}
|
||||
@@ -956,7 +1001,7 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
openFlyout(which: 'quick' | 'folders' | 'tags' | 'trash') {
|
||||
openFlyout(which: 'quick' | 'folders' | 'tags' | 'ai' | 'trash') {
|
||||
this.cancelCloseFlyout();
|
||||
this.hoveredFlyout = which;
|
||||
}
|
||||
@@ -976,6 +1021,21 @@ export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
flyoutTitle(which: 'quick' | 'folders' | 'tags' | 'ai' | 'trash' | 'help' | 'about' | 'tests' | 'playground' | null): string {
|
||||
switch (which) {
|
||||
case 'quick': return 'Quick Links';
|
||||
case 'folders': return 'Folders';
|
||||
case 'tags': return 'Tags';
|
||||
case 'ai': return 'AI Tools';
|
||||
case 'trash': return 'Trash';
|
||||
case 'help': return 'Help';
|
||||
case 'about': return 'About';
|
||||
case 'tests': return 'Tests';
|
||||
case 'playground': return 'Playground';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
onMarkdownPlaygroundSelected(): void {
|
||||
if (this.responsive.isMobile()) {
|
||||
this.mobileNav.setActiveTab('page');
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Type des actions IA disponibles
|
||||
*/
|
||||
export type AIAction =
|
||||
| 'generate-description'
|
||||
| 'summarize'
|
||||
| 'classify-by-theme'
|
||||
| 'analyze-style'
|
||||
| 'export';
|
||||
|
||||
/**
|
||||
* Interface pour les résultats d'actions IA
|
||||
*/
|
||||
export interface AIActionResult {
|
||||
success: boolean;
|
||||
action: AIAction;
|
||||
noteIds: string[];
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface pour une action IA dans le menu
|
||||
*/
|
||||
export interface AIToolItem {
|
||||
id: AIAction;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
requiresSelection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service gérant toutes les fonctionnalités d'intelligence artificielle
|
||||
* Fournit des méthodes pour générer des descriptions, résumer du contenu,
|
||||
* classer par thème, analyser le style et exporter.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AIToolsService {
|
||||
private vault = inject(VaultService);
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// État de la dernière action exécutée (pour Ctrl+Alt+Enter)
|
||||
private lastActionSig = signal<AIAction | null>(null);
|
||||
lastAction = this.lastActionSig.asReadonly();
|
||||
|
||||
// État du chargement
|
||||
private loadingSig = signal<boolean>(false);
|
||||
loading = this.loadingSig.asReadonly();
|
||||
|
||||
// Liste des outils disponibles
|
||||
readonly tools: AIToolItem[] = [
|
||||
{
|
||||
id: 'generate-description',
|
||||
label: 'Rédiger description',
|
||||
icon: '✍️',
|
||||
description: 'Génère automatiquement une description dans les propriétés',
|
||||
enabled: true,
|
||||
requiresSelection: true
|
||||
},
|
||||
{
|
||||
id: 'summarize',
|
||||
label: 'Résumer contenu',
|
||||
icon: '🧠',
|
||||
description: 'Produit un résumé des notes sélectionnées',
|
||||
enabled: true,
|
||||
requiresSelection: true
|
||||
},
|
||||
{
|
||||
id: 'classify-by-theme',
|
||||
label: 'Classer par thème',
|
||||
icon: '🗂️',
|
||||
description: 'Analyse le contenu et suggère des tags',
|
||||
enabled: true,
|
||||
requiresSelection: true
|
||||
},
|
||||
{
|
||||
id: 'analyze-style',
|
||||
label: 'Analyser le style',
|
||||
icon: '🔍',
|
||||
description: 'Statistiques de lecture (ton, longueur, complexité)',
|
||||
enabled: true,
|
||||
requiresSelection: true
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
label: 'Convertir / Exporter',
|
||||
icon: '🧾',
|
||||
description: 'Génération vers Markdown, PDF, DOCX ou JSON',
|
||||
enabled: true,
|
||||
requiresSelection: false
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Obtient un outil par son ID
|
||||
*/
|
||||
getTool(actionId: AIAction): AIToolItem | undefined {
|
||||
return this.tools.find(t => t.id === actionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une description pour les notes sélectionnées
|
||||
* TODO: Implémenter l'appel API Gemini/OpenAI
|
||||
*/
|
||||
async generateDescription(notes: Note[]): Promise<AIActionResult> {
|
||||
if (notes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
action: 'generate-description',
|
||||
noteIds: [],
|
||||
error: 'Aucune note sélectionnée'
|
||||
};
|
||||
}
|
||||
|
||||
this.loadingSig.set(true);
|
||||
this.lastActionSig.set('generate-description');
|
||||
|
||||
try {
|
||||
console.log('[AIToolsService] Génération de description pour:', notes.map(n => n.title));
|
||||
const outputs: Array<{ noteId: string; description: string }> = [];
|
||||
|
||||
for (const n of notes) {
|
||||
const full = await this.ensureFullNote(n);
|
||||
if (!full.text) continue;
|
||||
const body = { text: full.text, title: full.title, maxChars: 140, model: 'gemini-1.5-flash', language: 'fr' };
|
||||
const resp = await firstValueFrom(this.http.post<any>('/api/integrations/gemini/description', body));
|
||||
const description = String(resp?.description || '').trim();
|
||||
if (description) {
|
||||
// Patch frontmatter
|
||||
await this.updateNoteFrontmatter(full.filePath, { description });
|
||||
outputs.push({ noteId: full.id, description });
|
||||
}
|
||||
}
|
||||
|
||||
const result = { success: true, action: 'generate-description' as AIAction, noteIds: notes.map(n => n.id), result: { descriptions: outputs } };
|
||||
console.log('[AIToolsService] ✅ Description générée et appliquée', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AIToolsService] ❌ Erreur génération description:', error);
|
||||
return {
|
||||
success: false,
|
||||
action: 'generate-description',
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.loadingSig.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résume le contenu des notes sélectionnées
|
||||
*/
|
||||
async summarize(notes: Note[]): Promise<AIActionResult> {
|
||||
if (notes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
action: 'summarize',
|
||||
noteIds: [],
|
||||
error: 'Aucune note sélectionnée'
|
||||
};
|
||||
}
|
||||
|
||||
this.loadingSig.set(true);
|
||||
this.lastActionSig.set('summarize');
|
||||
|
||||
try {
|
||||
console.log('[AIToolsService] Résumé du contenu pour:', notes.map(n => n.title));
|
||||
const outputs: Array<{ noteId: string; summary: string }> = [];
|
||||
for (const n of notes) {
|
||||
const full = await this.ensureFullNote(n);
|
||||
if (!full.text) continue;
|
||||
const body = { text: full.text, title: full.title, maxChars: 240, model: 'gemini-1.5-flash', language: 'fr' };
|
||||
const resp = await firstValueFrom(this.http.post<any>('/api/integrations/gemini/summarize', body));
|
||||
const summary = String(resp?.summary || '').trim();
|
||||
if (summary) outputs.push({ noteId: full.id, summary });
|
||||
}
|
||||
const result = { success: true, action: 'summarize' as AIAction, noteIds: notes.map(n => n.id), result: { summaries: outputs } };
|
||||
console.log('[AIToolsService] ✅ Résumé généré', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AIToolsService] ❌ Erreur résumé:', error);
|
||||
return {
|
||||
success: false,
|
||||
action: 'summarize',
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.loadingSig.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe les notes par thème et suggère des tags
|
||||
* TODO: Implémenter l'appel API Gemini/OpenAI
|
||||
*/
|
||||
async classifyByTheme(notes: Note[]): Promise<AIActionResult> {
|
||||
if (notes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
action: 'classify-by-theme',
|
||||
noteIds: [],
|
||||
error: 'Aucune note sélectionnée'
|
||||
};
|
||||
}
|
||||
|
||||
this.loadingSig.set(true);
|
||||
this.lastActionSig.set('classify-by-theme');
|
||||
|
||||
try {
|
||||
console.log('[AIToolsService] Classification par thème pour:', notes.map(n => n.title));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
action: 'classify-by-theme' as AIAction,
|
||||
noteIds: notes.map(n => n.id),
|
||||
result: {
|
||||
classifications: notes.map(n => ({
|
||||
noteId: n.id,
|
||||
suggestedTags: ['theme-1', 'theme-2', 'category-a']
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[AIToolsService] ✅ Classification terminée avec succès');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AIToolsService] ❌ Erreur classification:', error);
|
||||
return {
|
||||
success: false,
|
||||
action: 'classify-by-theme',
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.loadingSig.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse le style d'écriture des notes
|
||||
* TODO: Implémenter l'appel API Gemini/OpenAI
|
||||
*/
|
||||
async analyzeStyle(notes: Note[]): Promise<AIActionResult> {
|
||||
if (notes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
action: 'analyze-style',
|
||||
noteIds: [],
|
||||
error: 'Aucune note sélectionnée'
|
||||
};
|
||||
}
|
||||
|
||||
this.loadingSig.set(true);
|
||||
this.lastActionSig.set('analyze-style');
|
||||
|
||||
try {
|
||||
console.log('[AIToolsService] Analyse du style pour:', notes.map(n => n.title));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
action: 'analyze-style' as AIAction,
|
||||
noteIds: notes.map(n => n.id),
|
||||
result: {
|
||||
analyses: notes.map(n => ({
|
||||
noteId: n.id,
|
||||
tone: 'Formel',
|
||||
readability: 'Moyenne',
|
||||
complexity: 7.5,
|
||||
wordCount: 450
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[AIToolsService] ✅ Analyse du style terminée');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AIToolsService] ❌ Erreur analyse style:', error);
|
||||
return {
|
||||
success: false,
|
||||
action: 'analyze-style',
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.loadingSig.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporte les notes vers différents formats
|
||||
* TODO: Implémenter via ExportService
|
||||
*/
|
||||
async export(notes: Note[], format: 'markdown' | 'pdf' | 'docx' | 'json' = 'markdown'): Promise<AIActionResult> {
|
||||
this.loadingSig.set(true);
|
||||
this.lastActionSig.set('export');
|
||||
|
||||
try {
|
||||
console.log('[AIToolsService] Export vers', format, 'pour:', notes.map(n => n.title));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
action: 'export' as AIAction,
|
||||
noteIds: notes.map(n => n.id),
|
||||
result: {
|
||||
format,
|
||||
exported: notes.length,
|
||||
message: `Export de ${notes.length} note(s) vers ${format.toUpperCase()} réussi`
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[AIToolsService] ✅ Export terminé');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[AIToolsService] ❌ Erreur export:', error);
|
||||
return {
|
||||
success: false,
|
||||
action: 'export',
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.loadingSig.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute l'action passée en paramètre sur les notes sélectionnées
|
||||
*/
|
||||
async executeAction(action: AIAction, notes: Note[]): Promise<AIActionResult> {
|
||||
switch (action) {
|
||||
case 'generate-description':
|
||||
return this.generateDescription(notes);
|
||||
case 'summarize':
|
||||
return this.summarize(notes);
|
||||
case 'classify-by-theme':
|
||||
return this.classifyByTheme(notes);
|
||||
case 'analyze-style':
|
||||
return this.analyzeStyle(notes);
|
||||
case 'export':
|
||||
return this.export(notes);
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
action,
|
||||
noteIds: notes.map(n => n.id),
|
||||
error: 'Action inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ré-exécute la dernière action utilisée (raccourci Ctrl+Alt+Enter)
|
||||
*/
|
||||
async repeatLastAction(notes: Note[]): Promise<AIActionResult | null> {
|
||||
const last = this.lastActionSig();
|
||||
if (!last) {
|
||||
console.warn('[AIToolsService] Aucune action précédente à répéter');
|
||||
return null;
|
||||
}
|
||||
return this.executeAction(last, notes);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
private async ensureFullNote(note: Note): Promise<{ id: string; title: string; text: string; filePath: string }> {
|
||||
try {
|
||||
const path = note.filePath || '';
|
||||
const id = note.id;
|
||||
if (path) {
|
||||
try { await (this.vault as any).ensureNoteLoadedByPath?.(path); } catch {}
|
||||
} else if (id) {
|
||||
try { await (this.vault as any).ensureNoteLoadedById?.(id); } catch {}
|
||||
}
|
||||
const full = (this.vault as any).getNoteById?.(id) || note;
|
||||
const text = String((full as any).rawContent ?? full.content ?? '');
|
||||
const title = String(full.title || note.title || '');
|
||||
const filePath = String(full.filePath || note.filePath || '');
|
||||
return { id, title, text, filePath };
|
||||
} catch {
|
||||
return { id: note.id, title: note.title || '', text: String((note as any).content || ''), filePath: note.filePath || '' };
|
||||
}
|
||||
}
|
||||
|
||||
private async updateNoteFrontmatter(filePath: string, patch: Record<string, any>): Promise<void> {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
const safeId = filePath.replace(/\.md$/i, '').split('/').map(encodeURIComponent).join('/');
|
||||
await firstValueFrom(this.http.patch(`/api/vault/notes/${safeId}`, { frontmatter: patch }));
|
||||
} catch (e) {
|
||||
console.warn('[AIToolsService] Failed to patch frontmatter for', filePath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Formats d'export supportés
|
||||
*/
|
||||
export type ExportFormat = 'markdown' | 'pdf' | 'docx' | 'json';
|
||||
|
||||
/**
|
||||
* Options d'export
|
||||
*/
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
includeMetadata?: boolean;
|
||||
includeTags?: boolean;
|
||||
includeBacklinks?: boolean;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat d'un export
|
||||
*/
|
||||
export interface ExportResult {
|
||||
success: boolean;
|
||||
format: ExportFormat;
|
||||
noteCount: number;
|
||||
fileName?: string;
|
||||
blob?: Blob;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service centralisé pour l'export de notes vers différents formats
|
||||
* Supporte: Markdown (.md), PDF, DOCX, JSON
|
||||
*
|
||||
* TODO: Implémenter les vrais exports avec les bibliothèques appropriées
|
||||
* - PDF: jsPDF ou pdfmake
|
||||
* - DOCX: docx.js
|
||||
* - Markdown: natif
|
||||
* - JSON: natif
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ExportService {
|
||||
// État du processus d'export
|
||||
private exportingSig = signal<boolean>(false);
|
||||
exporting = this.exportingSig.asReadonly();
|
||||
|
||||
// Progression de l'export (0-100)
|
||||
private progressSig = signal<number>(0);
|
||||
progress = this.progressSig.asReadonly();
|
||||
|
||||
/**
|
||||
* Exporte une seule note
|
||||
*/
|
||||
async exportNote(note: Note, options: ExportOptions): Promise<ExportResult> {
|
||||
return this.exportNotes([note], options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporte plusieurs notes
|
||||
*/
|
||||
async exportNotes(notes: Note[], options: ExportOptions): Promise<ExportResult> {
|
||||
if (notes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
format: options.format,
|
||||
noteCount: 0,
|
||||
error: 'Aucune note à exporter'
|
||||
};
|
||||
}
|
||||
|
||||
this.exportingSig.set(true);
|
||||
this.progressSig.set(0);
|
||||
|
||||
try {
|
||||
console.log(`[ExportService] Début export ${notes.length} note(s) vers ${options.format.toUpperCase()}`);
|
||||
|
||||
// Simulation de progression
|
||||
for (let i = 0; i <= 100; i += 20) {
|
||||
this.progressSig.set(i);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
let result: ExportResult;
|
||||
|
||||
switch (options.format) {
|
||||
case 'markdown':
|
||||
result = await this.exportToMarkdown(notes, options);
|
||||
break;
|
||||
case 'pdf':
|
||||
result = await this.exportToPDF(notes, options);
|
||||
break;
|
||||
case 'docx':
|
||||
result = await this.exportToDOCX(notes, options);
|
||||
break;
|
||||
case 'json':
|
||||
result = await this.exportToJSON(notes, options);
|
||||
break;
|
||||
default:
|
||||
result = {
|
||||
success: false,
|
||||
format: options.format,
|
||||
noteCount: notes.length,
|
||||
error: 'Format non supporté'
|
||||
};
|
||||
}
|
||||
|
||||
this.progressSig.set(100);
|
||||
console.log('[ExportService] ✅ Export terminé:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ExportService] ❌ Erreur export:', error);
|
||||
return {
|
||||
success: false,
|
||||
format: options.format,
|
||||
noteCount: notes.length,
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
} finally {
|
||||
this.exportingSig.set(false);
|
||||
this.progressSig.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export vers Markdown
|
||||
* TODO: Implémenter la logique réelle
|
||||
*/
|
||||
private async exportToMarkdown(notes: Note[], options: ExportOptions): Promise<ExportResult> {
|
||||
console.log('[ExportService] Export Markdown - TODO: implémentation réelle');
|
||||
|
||||
// Placeholder: créer un fichier markdown simple
|
||||
const content = notes.map(note => {
|
||||
let md = `# ${note.title}\n\n`;
|
||||
|
||||
if (options.includeMetadata && note.frontmatter) {
|
||||
md += '---\n';
|
||||
md += Object.entries(note.frontmatter)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n');
|
||||
md += '\n---\n\n';
|
||||
}
|
||||
|
||||
if (options.includeTags && note.tags?.length) {
|
||||
md += `Tags: ${note.tags.join(', ')}\n\n`;
|
||||
}
|
||||
|
||||
md += note.content || note.rawContent || '';
|
||||
md += '\n\n---\n\n';
|
||||
|
||||
return md;
|
||||
}).join('\n');
|
||||
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const fileName = notes.length === 1
|
||||
? `${notes[0].title}.md`
|
||||
: `export-${notes.length}-notes.md`;
|
||||
|
||||
// Trigger download
|
||||
this.downloadBlob(blob, fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
format: 'markdown',
|
||||
noteCount: notes.length,
|
||||
fileName,
|
||||
blob
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export vers PDF
|
||||
* TODO: Intégrer jsPDF ou pdfmake
|
||||
*/
|
||||
private async exportToPDF(notes: Note[], options: ExportOptions): Promise<ExportResult> {
|
||||
console.log('[ExportService] Export PDF - TODO: intégrer jsPDF/pdfmake');
|
||||
|
||||
// Placeholder
|
||||
return {
|
||||
success: true,
|
||||
format: 'pdf',
|
||||
noteCount: notes.length,
|
||||
fileName: `export-${notes.length}-notes.pdf`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export vers DOCX
|
||||
* TODO: Intégrer docx.js
|
||||
*/
|
||||
private async exportToDOCX(notes: Note[], options: ExportOptions): Promise<ExportResult> {
|
||||
console.log('[ExportService] Export DOCX - TODO: intégrer docx.js');
|
||||
|
||||
// Placeholder
|
||||
return {
|
||||
success: true,
|
||||
format: 'docx',
|
||||
noteCount: notes.length,
|
||||
fileName: `export-${notes.length}-notes.docx`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export vers JSON
|
||||
*/
|
||||
private async exportToJSON(notes: Note[], options: ExportOptions): Promise<ExportResult> {
|
||||
console.log('[ExportService] Export JSON');
|
||||
|
||||
const exportData = notes.map(note => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
fileName: note.fileName,
|
||||
filePath: note.filePath,
|
||||
content: note.content || note.rawContent,
|
||||
tags: options.includeTags ? note.tags : undefined,
|
||||
frontmatter: options.includeMetadata ? note.frontmatter : undefined,
|
||||
backlinks: options.includeBacklinks ? note.backlinks : undefined,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
mtime: note.mtime
|
||||
}));
|
||||
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const fileName = notes.length === 1
|
||||
? `${notes[0].title}.json`
|
||||
: `export-${notes.length}-notes.json`;
|
||||
|
||||
// Trigger download
|
||||
this.downloadBlob(blob, fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
format: 'json',
|
||||
noteCount: notes.length,
|
||||
fileName,
|
||||
blob
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche le téléchargement d'un Blob
|
||||
*/
|
||||
private downloadBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient les formats d'export disponibles
|
||||
*/
|
||||
getAvailableFormats(): ExportFormat[] {
|
||||
return ['markdown', 'pdf', 'docx', 'json'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un format est supporté
|
||||
*/
|
||||
isFormatSupported(format: string): format is ExportFormat {
|
||||
return ['markdown', 'pdf', 'docx', 'json'].includes(format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import type { Note, NoteFrontmatter } from '../../types';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Résultat d'une tâche IA Gemini
|
||||
*/
|
||||
export interface GeminiTaskResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type de tâche IA disponible
|
||||
*/
|
||||
export type GeminiTaskType =
|
||||
| 'generate-description'
|
||||
| 'generate-tags'
|
||||
| 'detect-type'
|
||||
| 'enrich-metadata'
|
||||
| 'suggest-links'
|
||||
| 'extract-keywords';
|
||||
|
||||
/**
|
||||
* Configuration d'une tâche IA
|
||||
*/
|
||||
export interface GeminiTask {
|
||||
id: GeminiTaskType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
beta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* État d'exécution d'une tâche
|
||||
*/
|
||||
export interface GeminiTaskExecution {
|
||||
taskId: GeminiTaskType;
|
||||
noteId: string;
|
||||
status: 'idle' | 'running' | 'success' | 'error';
|
||||
progress?: number;
|
||||
result?: GeminiTaskResult;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE PRINCIPAL
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service de gestion des tâches IA Gemini
|
||||
*
|
||||
* Ce service orchestre les différentes fonctionnalités d'intelligence artificielle
|
||||
* appliquées aux notes ObsiViewer:
|
||||
* - Génération de résumés
|
||||
* - Suggestion de tags
|
||||
* - Détection de type de contenu
|
||||
* - Enrichissement des métadonnées
|
||||
* - Suggestions de liens entre notes
|
||||
*
|
||||
* @architecture
|
||||
* Le service utilise Angular Signals pour la réactivité et communique avec
|
||||
* VaultService pour les modifications de notes.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GeminiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly vault = inject(VaultService);
|
||||
|
||||
// ========================================
|
||||
// STATE SIGNALS
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Signal de l'exécution en cours
|
||||
*/
|
||||
readonly currentExecution = signal<GeminiTaskExecution | null>(null);
|
||||
|
||||
/**
|
||||
* Signal de l'état de disponibilité du service
|
||||
*/
|
||||
readonly isAvailable = signal<boolean>(true);
|
||||
|
||||
/**
|
||||
* Signal du compteur de tâches exécutées
|
||||
*/
|
||||
readonly tasksCount = signal<number>(0);
|
||||
|
||||
// ========================================
|
||||
// CONFIGURATION DES TÂCHES
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Catalogue des tâches IA disponibles
|
||||
*/
|
||||
readonly availableTasks: GeminiTask[] = [
|
||||
{
|
||||
id: 'generate-description',
|
||||
label: 'Résumé automatique',
|
||||
description: 'Génère une description courte (une ligne) et l\'ajoute au frontmatter YAML',
|
||||
icon: '✨',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'generate-tags',
|
||||
label: 'Tags intelligents',
|
||||
description: 'Suggère des tags pertinents basés sur le contenu de la note',
|
||||
icon: '🏷️',
|
||||
enabled: false,
|
||||
beta: true
|
||||
},
|
||||
{
|
||||
id: 'detect-type',
|
||||
label: 'Détection de type',
|
||||
description: 'Identifie automatiquement le type de note (article, meeting, task, etc.)',
|
||||
icon: '🔍',
|
||||
enabled: false,
|
||||
beta: true
|
||||
},
|
||||
{
|
||||
id: 'enrich-metadata',
|
||||
label: 'Enrichir les métadonnées',
|
||||
description: 'Complète automatiquement les champs manquants du frontmatter',
|
||||
icon: '📋',
|
||||
enabled: false,
|
||||
beta: true
|
||||
},
|
||||
{
|
||||
id: 'suggest-links',
|
||||
label: 'Suggestions de liens',
|
||||
description: 'Propose des liens vers d\'autres notes connexes',
|
||||
icon: '🔗',
|
||||
enabled: false,
|
||||
beta: true
|
||||
},
|
||||
{
|
||||
id: 'extract-keywords',
|
||||
label: 'Extraction de mots-clés',
|
||||
description: 'Identifie les concepts clés et termes importants',
|
||||
icon: '🔑',
|
||||
enabled: false,
|
||||
beta: true
|
||||
}
|
||||
];
|
||||
|
||||
// ========================================
|
||||
// TÂCHE 1: RÉSUMÉ AUTOMATIQUE → DESCRIPTION
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Génère un résumé automatique d'une note et l'ajoute dans la propriété YAML "description"
|
||||
*
|
||||
* @param noteId - ID de la note à résumer
|
||||
* @returns Résultat de l'opération avec le résumé généré
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await geminiService.generateDescription('my-note');
|
||||
* if (result.success) {
|
||||
* console.log('Description ajoutée:', result.data.description);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async generateDescription(noteId: string): Promise<GeminiTaskResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Mettre à jour l'état d'exécution
|
||||
this.currentExecution.set({
|
||||
taskId: 'generate-description',
|
||||
noteId,
|
||||
status: 'running',
|
||||
progress: 0,
|
||||
startTime
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Récupérer la note
|
||||
const note = this.vault.getNoteById(noteId);
|
||||
if (!note) {
|
||||
throw new Error('Note introuvable');
|
||||
}
|
||||
|
||||
this.updateProgress(20);
|
||||
|
||||
// 2. Extraire le contenu textuel (sans frontmatter)
|
||||
const textContent = this.extractTextContent(note);
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
throw new Error('Aucun contenu textuel trouvé dans la note');
|
||||
}
|
||||
|
||||
this.updateProgress(40);
|
||||
|
||||
// 3. Générer le résumé (simulation IA pour le MVP)
|
||||
const description = await this.generateSummary(textContent, note.title);
|
||||
|
||||
this.updateProgress(60);
|
||||
|
||||
// 4. Mettre à jour le frontmatter avec la nouvelle description
|
||||
const updatedFrontmatter: NoteFrontmatter = {
|
||||
...note.frontmatter,
|
||||
description
|
||||
};
|
||||
|
||||
this.updateProgress(80);
|
||||
|
||||
// 5. Sauvegarder via l'API (utiliser le chemin réel sans extension)
|
||||
const pathId = (note.filePath || '').replace(/\.md$/i, '');
|
||||
await this.updateNoteFrontmatter(pathId, updatedFrontmatter);
|
||||
|
||||
this.updateProgress(100);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const result: GeminiTaskResult = {
|
||||
success: true,
|
||||
data: { description, noteId },
|
||||
duration
|
||||
};
|
||||
|
||||
// Marquer comme terminé avec succès
|
||||
this.currentExecution.update(exec => exec ? {
|
||||
...exec,
|
||||
status: 'success',
|
||||
result,
|
||||
endTime: Date.now()
|
||||
} : null);
|
||||
|
||||
this.tasksCount.update(count => count + 1);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
|
||||
|
||||
const result: GeminiTaskResult = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
duration
|
||||
};
|
||||
|
||||
// Marquer comme erreur
|
||||
this.currentExecution.update(exec => exec ? {
|
||||
...exec,
|
||||
status: 'error',
|
||||
result,
|
||||
endTime: Date.now()
|
||||
} : null);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES UTILITAIRES PRIVÉES
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Extrait le contenu textuel d'une note (sans frontmatter, code, etc.)
|
||||
*/
|
||||
private extractTextContent(note: Note): string {
|
||||
let content = note.content || note.rawContent || '';
|
||||
|
||||
// Retirer les blocs de code
|
||||
content = content.replace(/```[\s\S]*?```/g, '');
|
||||
content = content.replace(/`[^`]+`/g, '');
|
||||
|
||||
// Retirer les images et liens
|
||||
content = content.replace(/!\[.*?\]\(.*?\)/g, '');
|
||||
content = content.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||
|
||||
// Retirer les titres markdown (garder le texte)
|
||||
content = content.replace(/^#{1,6}\s+/gm, '');
|
||||
|
||||
// Retirer les listes
|
||||
content = content.replace(/^[\s]*[-*+]\s+/gm, '');
|
||||
content = content.replace(/^[\s]*\d+\.\s+/gm, '');
|
||||
|
||||
// Nettoyer les espaces multiples
|
||||
content = content.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un résumé intelligent d'un texte
|
||||
*
|
||||
* @note Pour le MVP, cette méthode utilise une approche heuristique simple.
|
||||
* À l'avenir, elle pourra être remplacée par un vrai appel à l'API Gemini.
|
||||
*/
|
||||
private async generateSummary(content: string, title: string): Promise<string> {
|
||||
// Simulation d'un délai réseau (à retirer en production)
|
||||
await this.delay(800);
|
||||
|
||||
// Approche heuristique simple pour le MVP:
|
||||
// 1. Prendre les 2-3 premières phrases significatives
|
||||
// 2. Limiter à ~100 caractères
|
||||
// 3. Nettoyer et formater
|
||||
|
||||
const sentences = content
|
||||
.split(/[.!?]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 20); // Ignorer les fragments trop courts
|
||||
|
||||
if (sentences.length === 0) {
|
||||
return `Note: ${title}`;
|
||||
}
|
||||
|
||||
// Prendre la première phrase significative
|
||||
let summary = sentences[0];
|
||||
|
||||
// Si trop longue, tronquer intelligemment
|
||||
if (summary.length > 120) {
|
||||
const words = summary.split(' ');
|
||||
summary = words.slice(0, 15).join(' ');
|
||||
|
||||
// Ajouter "..." si on a tronqué
|
||||
if (words.length > 15) {
|
||||
summary += '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Capitaliser la première lettre
|
||||
summary = summary.charAt(0).toUpperCase() + summary.slice(1);
|
||||
|
||||
// S'assurer qu'on finit par un point
|
||||
if (!summary.match(/[.!?]$/)) {
|
||||
summary += '.';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le frontmatter d'une note via l'API
|
||||
*/
|
||||
private async updateNoteFrontmatter(
|
||||
noteId: string,
|
||||
frontmatter: NoteFrontmatter
|
||||
): Promise<void> {
|
||||
try {
|
||||
const safeId = noteId
|
||||
.split('/')
|
||||
.map(seg => encodeURIComponent(seg))
|
||||
.join('/');
|
||||
await firstValueFrom(
|
||||
this.http.patch(`/api/vault/notes/${safeId}`, { frontmatter })
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[GeminiService] Failed to update note frontmatter:', error);
|
||||
throw new Error('Échec de la mise à jour de la note');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la progression de la tâche en cours
|
||||
*/
|
||||
private updateProgress(progress: number): void {
|
||||
this.currentExecution.update(exec =>
|
||||
exec ? { ...exec, progress } : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaire de délai (pour simulation)
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES PUBLIQUES DE CONTRÔLE
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Réinitialise l'état d'exécution
|
||||
*/
|
||||
resetExecution(): void {
|
||||
this.currentExecution.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une tâche est disponible
|
||||
*/
|
||||
isTaskAvailable(taskId: GeminiTaskType): boolean {
|
||||
const task = this.availableTasks.find(t => t.id === taskId);
|
||||
return task?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active/désactive une tâche (pour les tâches beta)
|
||||
*/
|
||||
toggleTask(taskId: GeminiTaskType, enabled: boolean): void {
|
||||
const task = this.availableTasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TÂCHES FUTURES (PLACEHOLDERS)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* @future Génère des tags intelligents
|
||||
*/
|
||||
async generateTags(noteId: string): Promise<GeminiTaskResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fonctionnalité non implémentée (beta)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @future Détecte le type de contenu
|
||||
*/
|
||||
async detectType(noteId: string): Promise<GeminiTaskResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fonctionnalité non implémentée (beta)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @future Enrichit les métadonnées
|
||||
*/
|
||||
async enrichMetadata(noteId: string): Promise<GeminiTaskResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fonctionnalité non implémentée (beta)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @future Suggère des liens entre notes
|
||||
*/
|
||||
async suggestLinks(noteId: string): Promise<GeminiTaskResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fonctionnalité non implémentée (beta)'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @future Extrait les mots-clés
|
||||
*/
|
||||
async extractKeywords(noteId: string): Promise<GeminiTaskResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Fonctionnalité non implémentée (beta)'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { IntegrationsService } from './integrations.service';
|
||||
import { GeminiStatusResponse } from './integrations.types';
|
||||
|
||||
describe('IntegrationsService', () => {
|
||||
let service: IntegrationsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [IntegrationsService]
|
||||
});
|
||||
|
||||
service = TestBed.inject(IntegrationsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('getGeminiStatus()', () => {
|
||||
it('should return status response for NOT_CONFIGURED', () => {
|
||||
const mockResponse: GeminiStatusResponse = {
|
||||
status: 'NOT_CONFIGURED',
|
||||
lastCheckedAt: null,
|
||||
details: {
|
||||
reason: 'missing_key',
|
||||
httpCode: null
|
||||
}
|
||||
};
|
||||
|
||||
service.getGeminiStatus().subscribe(response => {
|
||||
expect(response.status).toBe('NOT_CONFIGURED');
|
||||
expect(response.details.reason).toBe('missing_key');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/status');
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should return status response for WORKING', () => {
|
||||
const mockResponse: GeminiStatusResponse = {
|
||||
status: 'WORKING',
|
||||
lastCheckedAt: '2025-01-15T10:00:00.000Z',
|
||||
details: {
|
||||
reason: 'ok',
|
||||
httpCode: 200
|
||||
}
|
||||
};
|
||||
|
||||
service.getGeminiStatus().subscribe(response => {
|
||||
expect(response.status).toBe('WORKING');
|
||||
expect(response.details.httpCode).toBe(200);
|
||||
expect(response.lastCheckedAt).toBe('2025-01-15T10:00:00.000Z');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/status');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle HTTP errors gracefully', () => {
|
||||
service.getGeminiStatus().subscribe({
|
||||
next: () => fail('should have failed with 500 error'),
|
||||
error: (error) => {
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.message).toContain('500');
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/status');
|
||||
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGeminiKey()', () => {
|
||||
it('should send POST request to test endpoint', () => {
|
||||
const mockResponse: GeminiStatusResponse = {
|
||||
status: 'WORKING',
|
||||
lastCheckedAt: '2025-01-15T10:00:00.000Z',
|
||||
details: {
|
||||
reason: 'ok',
|
||||
httpCode: 200
|
||||
}
|
||||
};
|
||||
|
||||
service.testGeminiKey().subscribe(response => {
|
||||
expect(response.status).toBe('WORKING');
|
||||
expect(response.details.reason).toBe('ok');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/test');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({});
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should return INVALID status on auth error', () => {
|
||||
const mockResponse: GeminiStatusResponse = {
|
||||
status: 'INVALID',
|
||||
lastCheckedAt: '2025-01-15T10:00:00.000Z',
|
||||
details: {
|
||||
reason: 'auth_error',
|
||||
httpCode: 401
|
||||
}
|
||||
};
|
||||
|
||||
service.testGeminiKey().subscribe(response => {
|
||||
expect(response.status).toBe('INVALID');
|
||||
expect(response.details.reason).toBe('auth_error');
|
||||
expect(response.details.httpCode).toBe(401);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/test');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle rate limiting (429)', () => {
|
||||
service.testGeminiKey().subscribe({
|
||||
next: () => fail('should have failed with 429 error'),
|
||||
error: (error) => {
|
||||
expect(error.message).toContain('Trop de requêtes');
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/test');
|
||||
req.flush({ message: 'Rate limited' }, { status: 429, statusText: 'Too Many Requests' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetGeminiCache()', () => {
|
||||
it('should send DELETE request to cache endpoint', () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
message: 'Cache réinitialisé'
|
||||
};
|
||||
|
||||
service.resetGeminiCache().subscribe(response => {
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.message).toBe('Cache réinitialisé');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/cache');
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle network errors', () => {
|
||||
service.getGeminiStatus().subscribe({
|
||||
next: () => fail('should have failed with network error'),
|
||||
error: (error) => {
|
||||
expect(error.message).toContain('contacter le serveur');
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('/api/integrations/gemini/status');
|
||||
req.error(new ProgressEvent('Network error'), { status: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, throwError, catchError } from 'rxjs';
|
||||
import { GeminiStatusResponse } from './integrations.types';
|
||||
|
||||
/**
|
||||
* Service Angular pour gérer les intégrations externes (Gemini, etc.)
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class IntegrationsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly BASE_URL = '/api/integrations/gemini';
|
||||
|
||||
/**
|
||||
* Obtient le statut actuel de la clé API Gemini
|
||||
*/
|
||||
getGeminiStatus(): Observable<GeminiStatusResponse> {
|
||||
return this.http.get<GeminiStatusResponse>(`${this.BASE_URL}/status`)
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute un test live de la clé API Gemini
|
||||
*/
|
||||
testGeminiKey(): Observable<GeminiStatusResponse> {
|
||||
return this.http.post<GeminiStatusResponse>(`${this.BASE_URL}/test`, {})
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise le cache du statut Gemini (debug)
|
||||
*/
|
||||
resetGeminiCache(): Observable<{ success: boolean; message: string }> {
|
||||
return this.http.delete<{ success: boolean; message: string }>(`${this.BASE_URL}/cache`)
|
||||
.pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestion centralisée des erreurs HTTP
|
||||
*/
|
||||
private handleError(error: HttpErrorResponse): Observable<never> {
|
||||
let errorMessage = 'Une erreur s\'est produite';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Erreur côté client
|
||||
errorMessage = `Erreur: ${error.error.message}`;
|
||||
} else {
|
||||
// Erreur côté serveur
|
||||
if (error.status === 429) {
|
||||
errorMessage = 'Trop de requêtes, veuillez patienter avant de retester';
|
||||
} else if (error.status === 0) {
|
||||
errorMessage = 'Impossible de contacter le serveur';
|
||||
} else {
|
||||
errorMessage = `Erreur ${error.status}: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[IntegrationsService]', errorMessage, error);
|
||||
return throwError(() => new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Types pour le système d'intégrations (Gemini, etc.)
|
||||
*/
|
||||
|
||||
export type GeminiStatus = 'NOT_CONFIGURED' | 'CONFIGURED_UNVERIFIED' | 'WORKING' | 'INVALID';
|
||||
|
||||
export type GeminiStatusReason =
|
||||
| 'missing_key'
|
||||
| 'not_tested'
|
||||
| 'ok'
|
||||
| 'auth_error'
|
||||
| 'network_error'
|
||||
| 'rate_limited'
|
||||
| 'unexpected_response';
|
||||
|
||||
export interface GeminiStatusDetails {
|
||||
reason: GeminiStatusReason;
|
||||
httpCode: number | null;
|
||||
message?: string;
|
||||
waitSeconds?: number;
|
||||
}
|
||||
|
||||
export interface GeminiStatusResponse {
|
||||
status: GeminiStatus;
|
||||
lastCheckedAt: string | null;
|
||||
details: GeminiStatusDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappage des statuts vers des labels UI
|
||||
*/
|
||||
export const GEMINI_STATUS_LABELS: Record<GeminiStatus, string> = {
|
||||
NOT_CONFIGURED: 'Non configurée',
|
||||
CONFIGURED_UNVERIFIED: 'Configurée (non testée)',
|
||||
WORKING: 'Fonctionnelle',
|
||||
INVALID: 'Invalide / Erreur'
|
||||
};
|
||||
|
||||
/**
|
||||
* Mappage des raisons vers des messages utilisateur
|
||||
*/
|
||||
export const GEMINI_REASON_MESSAGES: Record<GeminiStatusReason, string> = {
|
||||
missing_key: 'La clé API n\'est pas configurée dans l\'environnement serveur',
|
||||
not_tested: 'La clé n\'a pas encore été testée',
|
||||
ok: 'La clé fonctionne correctement',
|
||||
auth_error: 'La clé est invalide ou a été révoquée',
|
||||
network_error: 'Impossible de contacter l\'API Gemini (timeout ou erreur réseau)',
|
||||
rate_limited: 'Limite de requêtes atteinte, veuillez patienter',
|
||||
unexpected_response: 'Réponse inattendue de l\'API Gemini'
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { SidebarStateService } from './sidebar-state.service';
|
||||
import { AIToolsService } from './ai-tools.service';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
/**
|
||||
* Service centralisé pour gérer les raccourcis clavier globaux de l'application
|
||||
*
|
||||
* Raccourcis implémentés:
|
||||
* - Ctrl + Shift + A: Ouvrir/fermer la section AI Tools
|
||||
* - Ctrl + Alt + Enter: Répéter la dernière action IA
|
||||
* - Ctrl + A: Sélectionner toutes les notes (géré dans NotesListComponent)
|
||||
* - Escape: Clear la sélection (géré dans NotesListComponent)
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KeyboardShortcutsService {
|
||||
private sidebar = inject(SidebarStateService);
|
||||
private aiTools = inject(AIToolsService);
|
||||
|
||||
// État pour passer les notes sélectionnées au service
|
||||
private selectedNotesSig = signal<Note[]>([]);
|
||||
selectedNotes = this.selectedNotesSig.asReadonly();
|
||||
|
||||
// État pour afficher les notifications de raccourcis
|
||||
private shortcutNotificationSig = signal<string | null>(null);
|
||||
shortcutNotification = this.shortcutNotificationSig.asReadonly();
|
||||
|
||||
constructor() {
|
||||
// Enregistrer les listeners globaux au démarrage
|
||||
if (typeof window !== 'undefined') {
|
||||
this.registerGlobalListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les notes sélectionnées (appelé par NotesListComponent)
|
||||
*/
|
||||
setSelectedNotes(notes: Note[]): void {
|
||||
this.selectedNotesSig.set(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche une notification temporaire de raccourci
|
||||
*/
|
||||
private showNotification(message: string): void {
|
||||
this.shortcutNotificationSig.set(message);
|
||||
setTimeout(() => {
|
||||
this.shortcutNotificationSig.set(null);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre les listeners globaux pour les raccourcis clavier
|
||||
*/
|
||||
private registerGlobalListeners(): void {
|
||||
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
this.handleKeydown(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les événements keydown globaux
|
||||
*/
|
||||
private handleKeydown(event: KeyboardEvent): void {
|
||||
// Ignore si on est dans un input/textarea/contenteditable
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
// Exception: Ctrl+Alt+Enter fonctionne même dans les inputs
|
||||
if (!(event.ctrlKey && event.altKey && event.key === 'Enter')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl + Shift + A: Toggle AI Tools section
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'A') {
|
||||
event.preventDefault();
|
||||
this.toggleAISection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl + Alt + Enter: Repeat last AI action
|
||||
if (event.ctrlKey && event.altKey && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.repeatLastAIAction();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle la section AI Tools dans le sidebar
|
||||
*/
|
||||
private toggleAISection(): void {
|
||||
// Basculer sans impacter les filtres/recherche
|
||||
this.sidebar.toggleAISection();
|
||||
this.showNotification('AI Tools');
|
||||
}
|
||||
|
||||
/**
|
||||
* Répète la dernière action IA sur les notes sélectionnées
|
||||
*/
|
||||
private async repeatLastAIAction(): Promise<void> {
|
||||
const notes = this.selectedNotesSig();
|
||||
const lastAction = this.aiTools.lastAction();
|
||||
|
||||
if (!lastAction) {
|
||||
console.warn('[KeyboardShortcuts] Aucune action IA précédente à répéter');
|
||||
this.showNotification('Aucune action IA précédente');
|
||||
return;
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
console.warn('[KeyboardShortcuts] Aucune note sélectionnée');
|
||||
this.showNotification('Sélectionnez des notes d\'abord');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[KeyboardShortcuts] Répétition de l'action "${lastAction}" sur ${notes.length} note(s)`);
|
||||
this.showNotification(`Répétition: ${this.getActionLabel(lastAction)}...`);
|
||||
|
||||
try {
|
||||
const result = await this.aiTools.repeatLastAction(notes);
|
||||
if (result?.success) {
|
||||
this.showNotification(`✅ ${this.getActionLabel(lastAction)} terminé`);
|
||||
} else {
|
||||
this.showNotification(`❌ Erreur: ${result?.error || 'Inconnue'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[KeyboardShortcuts] Erreur répétition action:', error);
|
||||
this.showNotification('❌ Erreur lors de l\'action IA');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient le label lisible d'une action IA
|
||||
*/
|
||||
private getActionLabel(action: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'generate-description': 'Génération description',
|
||||
'summarize': 'Résumé',
|
||||
'classify-by-theme': 'Classification',
|
||||
'analyze-style': 'Analyse style',
|
||||
'export': 'Export'
|
||||
};
|
||||
return labels[action] || action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtient la liste des raccourcis disponibles (pour aide/documentation)
|
||||
*/
|
||||
getShortcutsList(): Array<{ keys: string; description: string }> {
|
||||
return [
|
||||
{
|
||||
keys: 'Ctrl + Shift + A',
|
||||
description: 'Ouvrir la section AI Tools'
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl + Alt + Enter',
|
||||
description: 'Répéter la dernière action IA sur les notes sélectionnées'
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl + A',
|
||||
description: 'Sélectionner toutes les notes (dans la liste)'
|
||||
},
|
||||
{
|
||||
keys: 'Escape',
|
||||
description: 'Effacer la sélection de notes'
|
||||
},
|
||||
{
|
||||
keys: 'Ctrl + Clic',
|
||||
description: 'Sélection multiple de notes'
|
||||
},
|
||||
{
|
||||
keys: 'Long press',
|
||||
description: 'Sélection multiple (mobile)'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
export type SortBy = 'title' | 'created' | 'updated';
|
||||
export type ViewMode = 'compact' | 'comfortable' | 'detailed';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export interface RequestStats {
|
||||
success: boolean;
|
||||
@@ -18,12 +19,14 @@ export class NotesListStateService {
|
||||
private viewModeSignal = signal<ViewMode>('comfortable');
|
||||
private lastRequestStatsSignal = signal<RequestStats | null>(null);
|
||||
private isLoadingSignal = signal<boolean>(false);
|
||||
private sortOrderSignal = signal<SortOrder>('desc');
|
||||
|
||||
// Computed signals
|
||||
readonly sortBy = computed(() => this.sortBySignal());
|
||||
readonly viewMode = computed(() => this.viewModeSignal());
|
||||
readonly lastRequestStats = computed(() => this.lastRequestStatsSignal());
|
||||
readonly isLoading = computed(() => this.isLoadingSignal());
|
||||
readonly sortOrder = computed(() => this.sortOrderSignal());
|
||||
|
||||
// Getters for direct access
|
||||
getSortBy(): SortBy {
|
||||
@@ -38,6 +41,10 @@ export class NotesListStateService {
|
||||
return this.lastRequestStatsSignal();
|
||||
}
|
||||
|
||||
getSortOrder(): SortOrder {
|
||||
return this.sortOrderSignal();
|
||||
}
|
||||
|
||||
// Setters
|
||||
setSortBy(sort: SortBy): void {
|
||||
this.sortBySignal.set(sort);
|
||||
@@ -59,6 +66,15 @@ export class NotesListStateService {
|
||||
this.isLoadingSignal.set(loading);
|
||||
}
|
||||
|
||||
setSortOrder(order: SortOrder): void {
|
||||
this.sortOrderSignal.set(order);
|
||||
}
|
||||
|
||||
toggleSortOrder(): void {
|
||||
const next: SortOrder = this.sortOrderSignal() === 'asc' ? 'desc' : 'asc';
|
||||
this.sortOrderSignal.set(next);
|
||||
}
|
||||
|
||||
// Helper to get display text for sort
|
||||
getSortLabel(): string {
|
||||
const labels: Record<SortBy, string> = {
|
||||
@@ -85,5 +101,7 @@ export class NotesListStateService {
|
||||
this.viewModeSignal.set('comfortable');
|
||||
this.lastRequestStatsSignal.set(null);
|
||||
this.isLoadingSignal.set(false);
|
||||
this.sortOrderSignal.set('desc');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { UrlStateService } from './url-state.service';
|
||||
|
||||
export type SidebarSection = 'quick' | 'folders' | 'tags' | null;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SidebarStateService {
|
||||
private openSectionSig = signal<SidebarSection>(null);
|
||||
|
||||
constructor(private url: UrlStateService) {}
|
||||
|
||||
openSection() { return this.openSectionSig(); }
|
||||
|
||||
open(section: Exclude<SidebarSection, null>) {
|
||||
if (this.openSectionSig() !== section) {
|
||||
this.openSectionSig.set(section);
|
||||
}
|
||||
// Reset filters/search when switching sections as per UX spec
|
||||
this.url.showAllAndReset();
|
||||
}
|
||||
}
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { UrlStateService } from './url-state.service';
|
||||
|
||||
export type SidebarSection = 'quick' | 'folders' | 'tags' | 'ai' | null;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SidebarStateService {
|
||||
private openSectionSig = signal<SidebarSection>(null);
|
||||
|
||||
constructor(private url: UrlStateService) {}
|
||||
|
||||
openSection() { return this.openSectionSig(); }
|
||||
|
||||
/**
|
||||
* Ouvre une section du sidebar. Pour 'ai', ne pas réinitialiser les filtres ni la recherche.
|
||||
*/
|
||||
open(section: Exclude<SidebarSection, null>) {
|
||||
const prev = this.openSectionSig();
|
||||
if (prev !== section) {
|
||||
this.openSectionSig.set(section);
|
||||
}
|
||||
// Réinitialiser uniquement pour les sections de navigation principales
|
||||
if (section === 'quick' || section === 'folders' || section === 'tags') {
|
||||
this.url.showAllAndReset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bascule l'ouverture de la section AI sans impacter la liste de notes.
|
||||
*/
|
||||
toggleAISection(): void {
|
||||
const curr = this.openSectionSig();
|
||||
this.openSectionSig.set(curr === 'ai' ? null : 'ai');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,10 +292,6 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
const path = this.filePathSig();
|
||||
if (!path) { this.fetchedContent.set(''); return; }
|
||||
if (vt === 'code' || vt === 'text') {
|
||||
// Do not attempt to fetch files inside the .obsidian folder (restricted)
|
||||
if (path.startsWith('.obsidian/')) {
|
||||
this.fetchedContent.set('');
|
||||
} else {
|
||||
const ts = Date.now();
|
||||
const url = `/vault/${encodeURI(path)}?v=${ts}`;
|
||||
console.log(`[SmartFileViewer] Fetching ${vt} file content from: ${url}`);
|
||||
@@ -342,7 +338,6 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
console.error(`[SmartFileViewer] Failed to fetch ${path}:`, err);
|
||||
this.fetchedContent.set('');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.fetchedContent.set('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user