docs: remove Phase 2 completion and executive summary files
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
(searchOptionsChange)="onHeaderSearchOptionsChange($event)"
|
||||
(markdownPlaygroundSelected)="setView('markdown-playground')"
|
||||
(parametersOpened)="setView('parameters')"
|
||||
(testsPanelRequested)="setView('tests-panel')"
|
||||
(helpPageRequested)="openHelpPage()"
|
||||
></app-shell-nimbus-layout>
|
||||
} @else {
|
||||
@@ -536,6 +537,8 @@
|
||||
</div>
|
||||
} @else if (activeView() === 'parameters') {
|
||||
<app-parameters></app-parameters>
|
||||
} @else if (activeView() === 'tests-panel') {
|
||||
<app-tests-panel></app-tests-panel>
|
||||
} @else {
|
||||
@if (activeView() === 'drawings') {
|
||||
@if (currentDrawingPath()) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { SearchOrchestratorService } from './core/search/search-orchestrator.ser
|
||||
import { LayoutModule } from '@angular/cdk/layout';
|
||||
import { ToastContainerComponent } from './app/shared/toast/toast-container.component';
|
||||
import { ParametersPage } from './app/features/parameters/parameters.page';
|
||||
import { TestsPanelComponent } from './app/features/tests/tests-panel.component';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@@ -67,6 +68,7 @@ interface TocEntry {
|
||||
MarkdownPlaygroundComponent,
|
||||
ToastContainerComponent,
|
||||
ParametersPage,
|
||||
TestsPanelComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@@ -92,7 +94,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
isOutlineOpen = signal<boolean>(false);
|
||||
outlineTab = signal<'outline' | 'settings'>('outline');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters'>('files');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel'>('files');
|
||||
currentDrawingPath = signal<string | null>(null);
|
||||
selectedNoteId = signal<string>('');
|
||||
sidebarSearchTerm = signal<string>('');
|
||||
@@ -878,7 +880,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
handle?.addEventListener('lostpointercapture', cleanup);
|
||||
}
|
||||
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters'): void {
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground' | 'parameters' | 'tests-panel'): void {
|
||||
const previousView = this.activeView();
|
||||
this.activeView.set(view);
|
||||
this.sidebarSearchTerm.set('');
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { Component, inject, signal, computed, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PerformanceProfilerService } from '../../services/performance-profiler.service';
|
||||
import { ClientCacheService } from '../../services/client-cache.service';
|
||||
import { NotePreloaderService } from '../../services/note-preloader.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-performance-monitor-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="performance-panel" *ngIf="isVisible()">
|
||||
<div class="panel-header">
|
||||
<h3>📊 Performance Monitor</h3>
|
||||
<button (click)="togglePanel()" class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-content" *ngIf="isPanelOpen()">
|
||||
<!-- Cache Stats -->
|
||||
<div class="stats-section">
|
||||
<h4>Cache Statistics</h4>
|
||||
<div class="stat-row">
|
||||
<span>Memory Cache:</span>
|
||||
<span>{{ cacheStats().memory.size }}/{{ cacheStats().memory.maxSize }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>Persistent Cache:</span>
|
||||
<span>{{ cacheStats().persistent.size }}/{{ cacheStats().persistent.maxSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preloader Stats -->
|
||||
<div class="stats-section">
|
||||
<h4>Preloader Status</h4>
|
||||
<div class="stat-row">
|
||||
<span>Queue Size:</span>
|
||||
<span>{{ preloaderStatus().queueSize }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>Loading:</span>
|
||||
<span>{{ preloaderStatus().loadingCount }}/{{ preloaderStatus().config.maxConcurrentLoads }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="stats-section">
|
||||
<h4>Performance Metrics</h4>
|
||||
<div class="metrics-list">
|
||||
<div *ngFor="let metric of getTopMetrics()" class="metric-item">
|
||||
<div class="metric-name">{{ metric.name }}</div>
|
||||
<div class="metric-values">
|
||||
<span class="avg">{{ metric.avg }}ms avg</span>
|
||||
<span class="p95">{{ metric.p95 }}ms p95</span>
|
||||
<span class="count">{{ metric.count }} samples</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottlenecks -->
|
||||
<div class="stats-section" *ngIf="bottlenecks().slowOperations.length > 0">
|
||||
<h4>⚠️ Slow Operations</h4>
|
||||
<div *ngFor="let op of bottlenecks().slowOperations" class="bottleneck-item">
|
||||
<div class="op-name">{{ op.operation }}</div>
|
||||
<div class="op-stats">
|
||||
<span>{{ op.avgDuration }}ms avg</span>
|
||||
<span *ngIf="op.failureRate > 0" class="failure-rate">
|
||||
{{ (op.failureRate * 100).toFixed(1) }}% failures
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button (click)="resetMetrics()" class="btn-reset">Reset Metrics</button>
|
||||
<button (click)="exportMetrics()" class="btn-export">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.performance-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
max-width: 400px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f9f9f9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.stats-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-row span:last-child {
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.metrics-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metric-values {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-values span {
|
||||
background: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.metric-values .avg {
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.metric-values .p95 {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
.metric-values .count {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.bottleneck-item {
|
||||
background: #fff3cd;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
border-left: 3px solid #ffc107;
|
||||
}
|
||||
|
||||
.op-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.op-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.failure-rate {
|
||||
color: #d32f2f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
color: #0066cc;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class PerformanceMonitorPanelComponent {
|
||||
private profiler = inject(PerformanceProfilerService);
|
||||
private cache = inject(ClientCacheService);
|
||||
private preloader = inject(NotePreloaderService);
|
||||
|
||||
isVisible = signal(typeof window !== 'undefined' && !this.isProduction());
|
||||
isPanelOpen = signal(false);
|
||||
|
||||
cacheStats = computed(() => this.cache.getStats());
|
||||
preloaderStatus = computed(() => this.preloader.getStatus());
|
||||
bottlenecks = computed(() => this.profiler.analyzeBottlenecks());
|
||||
|
||||
constructor() {
|
||||
// Auto-refresh stats every 2 seconds
|
||||
if (typeof window !== 'undefined') {
|
||||
setInterval(() => {
|
||||
// Trigger computed updates
|
||||
this.cacheStats();
|
||||
this.preloaderStatus();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
getTopMetrics() {
|
||||
const metrics = this.profiler.getMetrics();
|
||||
return Object.entries(metrics)
|
||||
.map(([name, data]: [string, any]) => ({
|
||||
name,
|
||||
avg: data.avgDuration,
|
||||
p95: data.p95Duration,
|
||||
count: data.sampleCount
|
||||
}))
|
||||
.sort((a, b) => b.avg - a.avg)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
togglePanel() {
|
||||
this.isPanelOpen.update(v => !v);
|
||||
}
|
||||
|
||||
resetMetrics() {
|
||||
this.profiler.reset();
|
||||
}
|
||||
|
||||
exportMetrics() {
|
||||
const data = this.profiler.exportMetrics();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `performance-metrics-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
private isProduction(): boolean {
|
||||
return typeof window !== 'undefined' && window.location.hostname !== 'localhost';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
interface TestResult {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
status: 'pending' | 'running' | 'success' | 'error';
|
||||
duration?: number;
|
||||
response?: any;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tests-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="tests-panel">
|
||||
<div class="panel-header">
|
||||
<h2>🧪 API Tests Panel</h2>
|
||||
<p class="panel-description">
|
||||
Test all ObsiViewer APIs and validate functionality. Each test shows execution time and response details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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 -->
|
||||
<div class="results-panel">
|
||||
<div class="results-header">
|
||||
<h3>Test Results</h3>
|
||||
<button type="button" class="clear-btn" (click)="clearResults()" [disabled]="results().length === 0">
|
||||
Clear Results
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-list" *ngIf="results().length > 0; else noResults">
|
||||
<div *ngFor="let result of results()" class="result-item" [ngClass]="getResultClass(result)">
|
||||
<div class="result-header">
|
||||
<span class="result-method">{{ result.method }}</span>
|
||||
<span class="result-endpoint">{{ result.endpoint }}</span>
|
||||
<span class="result-status">{{ result.status.toUpperCase() }}</span>
|
||||
<span class="result-duration" *ngIf="result.duration">({{ result.duration }}ms)</span>
|
||||
</div>
|
||||
<div class="result-details" *ngIf="result.response || result.error">
|
||||
<pre class="result-json">{{ result.response ? (result.response | json) : result.error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noResults>
|
||||
<div class="no-results">
|
||||
<p>No tests run yet. Click a button above to start testing!</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.tests-panel {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.test-sections {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.test-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-btn--primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--primary:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.test-btn--secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--secondary:hover:not(:disabled) {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
.test-btn--info {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--info:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
}
|
||||
|
||||
.test-btn--warning {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.test-btn--warning:hover:not(:disabled) {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.test-btn--danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.test-btn--accent {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--accent:hover:not(:disabled) {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.test-btn--neutral {
|
||||
background: #6f42c1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-btn--neutral:hover:not(:disabled) {
|
||||
background: #5a32a3;
|
||||
}
|
||||
|
||||
.file-test-form {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.results-panel {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.results-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 6px 12px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.clear-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item--success {
|
||||
background: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.result-item--error {
|
||||
background: #f8d7da;
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.result-item--running {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.result-item--pending {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-method {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
background: #e7f3ff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-endpoint {
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-status {
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-item--success .result-status {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.result-item--error .result-status {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.result-item--running .result-status {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.result-item--pending .result-status {
|
||||
background: #f8f9fa;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.result-duration {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-details {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.result-json {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-results p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sse-events {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sse-events h4 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 5px 0;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.event-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.event-data {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #666;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TestsPanelComponent {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// Test form data
|
||||
testFilePath = signal('');
|
||||
testFileContent = signal('');
|
||||
testNoteId = signal('');
|
||||
|
||||
// Test state
|
||||
results = signal<TestResult[]>([]);
|
||||
sseEvents = signal<Array<{type: string, data: any, timestamp: Date}>>([]);
|
||||
isRunning = signal(false);
|
||||
|
||||
// SSE connection
|
||||
private sseConnection: EventSource | null = null;
|
||||
|
||||
async runHealthCheck(): Promise<void> {
|
||||
await this.runTest('GET', '/api/health', async () => {
|
||||
return this.http.get('/api/health').toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runVaultMetadata(): Promise<void> {
|
||||
await this.runTest('GET', '/api/vault/metadata', async () => {
|
||||
return this.http.get('/api/vault/metadata').toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runVaultPaginated(): Promise<void> {
|
||||
await this.runTest('GET', '/api/vault/metadata/paginated', async () => {
|
||||
return this.http.get('/api/vault/metadata/paginated').toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runFileRead(): Promise<void> {
|
||||
const path = this.testFilePath().trim();
|
||||
if (!path) return;
|
||||
|
||||
await this.runTest('GET', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
||||
return this.http.get(`/api/files?path=${encodeURIComponent(path)}`, { responseType: 'text' }).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runFileWrite(): Promise<void> {
|
||||
const path = this.testFilePath().trim();
|
||||
const content = this.testFileContent().trim();
|
||||
if (!path || !content) return;
|
||||
|
||||
await this.runTest('PUT', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
||||
return this.http.put(`/api/files?path=${encodeURIComponent(path)}`, content, {
|
||||
headers: { 'Content-Type': 'text/markdown; charset=utf-8' }
|
||||
}).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runFileDelete(): Promise<void> {
|
||||
const path = this.testFilePath().trim();
|
||||
if (!path) return;
|
||||
|
||||
await this.runTest('DELETE', `/api/files?path=${encodeURIComponent(path)}`, async () => {
|
||||
return this.http.delete(`/api/files?path=${encodeURIComponent(path)}`).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runTogglePublish(): Promise<void> {
|
||||
const noteId = this.testNoteId().trim();
|
||||
if (!noteId) return;
|
||||
|
||||
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/publish`, async () => {
|
||||
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/publish`, { value: true }).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runToggleFavorite(): Promise<void> {
|
||||
const noteId = this.testNoteId().trim();
|
||||
if (!noteId) return;
|
||||
|
||||
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/favoris`, async () => {
|
||||
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/favoris`, { value: true }).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runToggleDraft(): Promise<void> {
|
||||
const noteId = this.testNoteId().trim();
|
||||
if (!noteId) return;
|
||||
|
||||
await this.runTest('PUT', `/api/notes/${encodeURIComponent(noteId)}/states/draft`, async () => {
|
||||
return this.http.put(`/api/notes/${encodeURIComponent(noteId)}/states/draft`, { value: true }).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
runSSETest(): void {
|
||||
if (this.sseConnection) {
|
||||
this.sseConnection.close();
|
||||
this.sseConnection = null;
|
||||
}
|
||||
|
||||
this.sseConnection = new EventSource('/api/vault/events');
|
||||
this.sseEvents.set([]);
|
||||
|
||||
this.sseConnection.onopen = () => {
|
||||
this.addResult({
|
||||
endpoint: '/api/vault/events',
|
||||
method: 'SSE',
|
||||
status: 'success',
|
||||
response: 'SSE connection established',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
};
|
||||
|
||||
this.sseConnection.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.sseEvents.update(events => [{
|
||||
type: data.event || 'message',
|
||||
data,
|
||||
timestamp: new Date()
|
||||
}, ...events.slice(0, 9)]); // Keep last 10 events
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
this.sseConnection.onerror = (error) => {
|
||||
this.addResult({
|
||||
endpoint: '/api/vault/events',
|
||||
method: 'SSE',
|
||||
status: 'error',
|
||||
error: 'SSE connection failed',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
this.sseConnection?.close();
|
||||
this.sseConnection = null;
|
||||
};
|
||||
}
|
||||
|
||||
async runSimulateEvent(): Promise<void> {
|
||||
await this.runTest('POST', '/api/simulate-event', async () => {
|
||||
return this.http.post('/api/simulate-event', { event: 'test', data: { message: 'Test event' } }).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runLogTest(): Promise<void> {
|
||||
const testLogs = [
|
||||
{ event: 'test_api_call', level: 'info', context: { route: '/api/test' }, data: { result: 'success' } },
|
||||
{ event: 'test_error', level: 'error', context: { route: '/api/test' }, data: { error: 'Test error' } }
|
||||
];
|
||||
|
||||
await this.runTest('POST', '/api/log', async () => {
|
||||
return this.http.post('/api/log', testLogs[0]).toPromise();
|
||||
});
|
||||
|
||||
await this.runTest('POST', '/api/logs', async () => {
|
||||
return this.http.post('/api/logs', testLogs[1]).toPromise();
|
||||
});
|
||||
}
|
||||
|
||||
async runBatchTest(): Promise<void> {
|
||||
this.isRunning.set(true);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
await this.runHealthCheck();
|
||||
|
||||
// Vault APIs
|
||||
await this.runVaultMetadata();
|
||||
await this.runVaultPaginated();
|
||||
|
||||
// File operations (using a test file)
|
||||
this.testFilePath.set('test-file.md');
|
||||
this.testFileContent.set('# Test File\n\nThis is a test file for API testing.');
|
||||
await this.runFileWrite();
|
||||
await this.runFileRead();
|
||||
|
||||
// State operations (using home note)
|
||||
this.testNoteId.set('home');
|
||||
await this.runTogglePublish();
|
||||
await this.runToggleFavorite();
|
||||
await this.runToggleDraft();
|
||||
|
||||
// SSE
|
||||
this.runSSETest();
|
||||
|
||||
// Logging
|
||||
await this.runLogTest();
|
||||
|
||||
// Clean up
|
||||
await this.runFileDelete();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Batch test error:', error);
|
||||
} finally {
|
||||
this.isRunning.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async runTest(method: string, endpoint: string, operation: () => Promise<any>): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const result: TestResult = {
|
||||
endpoint,
|
||||
method,
|
||||
status: 'running',
|
||||
timestamp: startTime
|
||||
};
|
||||
|
||||
this.results.update(results => [result, ...results]);
|
||||
|
||||
try {
|
||||
const response = await operation();
|
||||
result.status = 'success';
|
||||
result.response = response;
|
||||
} catch (error: any) {
|
||||
result.status = 'error';
|
||||
result.error = error.message || error.toString();
|
||||
}
|
||||
|
||||
result.duration = Date.now() - startTime;
|
||||
this.results.update(results => results.map(r => r === result ? { ...result } : r));
|
||||
}
|
||||
|
||||
private addResult(result: TestResult): void {
|
||||
this.results.update(results => [result, ...results]);
|
||||
}
|
||||
|
||||
clearResults(): void {
|
||||
this.results.set([]);
|
||||
this.sseEvents.set([]);
|
||||
if (this.sseConnection) {
|
||||
this.sseConnection.close();
|
||||
this.sseConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
getResultClass(result: TestResult): string {
|
||||
return `result-item--${result.status}`;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { MarkdownPlaygroundComponent } from './markdown-playground/markdown-playground.component';
|
||||
import { TestsPanelComponent } from './tests-panel.component';
|
||||
|
||||
export const TESTS_ROUTES: Routes = [
|
||||
{
|
||||
path: 'markdown',
|
||||
component: MarkdownPlaygroundComponent
|
||||
},
|
||||
{
|
||||
path: 'panel',
|
||||
component: TestsPanelComponent
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'markdown'
|
||||
redirectTo: 'panel'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -78,7 +78,7 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
<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('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('playground')" (mouseleave)="scheduleCloseFlyout()" title="Markdown Playground">🧪</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('tests')" (mouseleave)="scheduleCloseFlyout()" title="Tests">🧪</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('help')" (mouseleave)="scheduleCloseFlyout()" title="Help">🆘</button>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('about')" (mouseleave)="scheduleCloseFlyout()" title="About">ℹ️</button>
|
||||
</aside>
|
||||
@@ -93,7 +93,8 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
(f === 'trash' ? 'Trash' :
|
||||
(f === 'help' ? 'Help' :
|
||||
(f === 'about' ? 'About' :
|
||||
(f === 'playground' ? 'Playground' : ''))))))
|
||||
(f === 'tests' ? 'Tests' :
|
||||
(f === 'playground' ? 'Playground' : '')))))))
|
||||
}}</div>
|
||||
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (click)="hoveredFlyout=null">✕</button>
|
||||
</div>
|
||||
@@ -131,6 +132,10 @@ import { AboutPanelComponent } from '../../features/about/about-panel.component'
|
||||
<div *ngSwitchCase="'about'" class="p-3">
|
||||
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onAboutSelected()">ℹ️ <span>About</span></button>
|
||||
</div>
|
||||
<div *ngSwitchCase="'tests'" class="p-3">
|
||||
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onTestsPanelSelected()">🔬 <span>API Tests Panel</span></button>
|
||||
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onMarkdownPlaygroundSelected()">📝 <span>Markdown Playground</span></button>
|
||||
</div>
|
||||
<div *ngSwitchCase="'playground'" class="p-3">
|
||||
<button type="button" class="w-full text-left flex items-center gap-2 px-3 py-2 text-sm rounded-lg hover:bg-surface1 dark:hover:bg-card" (click)="onMarkdownPlaygroundSelected()">🧪 <span>Markdown Playground</span></button>
|
||||
</div>
|
||||
@@ -333,10 +338,11 @@ export class AppShellNimbusLayoutComponent {
|
||||
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
|
||||
@Output() parametersOpened = new EventEmitter<void>();
|
||||
@Output() helpPageRequested = new EventEmitter<void>();
|
||||
@Output() testsPanelRequested = new EventEmitter<void>();
|
||||
|
||||
folderFilter: string | null = null;
|
||||
listQuery: string = '';
|
||||
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'trash' | 'help' | 'about' | 'playground' | null = null;
|
||||
hoveredFlyout: 'quick' | 'folders' | 'tags' | '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;
|
||||
@@ -591,6 +597,10 @@ export class AppShellNimbusLayoutComponent {
|
||||
this.markdownPlaygroundSelected.emit();
|
||||
}
|
||||
|
||||
onTestsPanelSelected(): void {
|
||||
this.testsPanelRequested.emit();
|
||||
}
|
||||
|
||||
onParametersOpen(): void {
|
||||
this.parametersOpened.emit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
interface CachedItem<T = any> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl?: number;
|
||||
accessCount: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClientCacheService {
|
||||
private memoryCache = new Map<string, CachedItem>();
|
||||
private persistentCache = new Map<string, CachedItem>();
|
||||
|
||||
private readonly maxMemoryItems = 50;
|
||||
private readonly maxPersistentItems = 200;
|
||||
|
||||
// Cache en mémoire pour la session active
|
||||
setMemory<T>(key: string, value: T, ttlMs = 30 * 60 * 1000) {
|
||||
const item: CachedItem<T> = {
|
||||
data: value,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttlMs,
|
||||
accessCount: 0
|
||||
};
|
||||
|
||||
this.memoryCache.set(key, item);
|
||||
this.cleanupMemory();
|
||||
}
|
||||
|
||||
// Cache persistant pour les notes fréquemment consultées
|
||||
setPersistent<T>(key: string, value: T) {
|
||||
const item: CachedItem<T> = {
|
||||
data: value,
|
||||
timestamp: Date.now(),
|
||||
accessCount: 0
|
||||
};
|
||||
|
||||
this.persistentCache.set(key, item);
|
||||
this.cleanupPersistent();
|
||||
}
|
||||
|
||||
// Récupérer un élément du cache
|
||||
get<T>(key: string): T | null {
|
||||
const now = Date.now();
|
||||
|
||||
// Essayer le cache mémoire d'abord
|
||||
const memoryItem = this.memoryCache.get(key) as CachedItem<T>;
|
||||
if (memoryItem) {
|
||||
if (this.isValid(memoryItem, now)) {
|
||||
memoryItem.accessCount++;
|
||||
return memoryItem.data;
|
||||
} else {
|
||||
this.memoryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Puis le cache persistant
|
||||
const persistentItem = this.persistentCache.get(key) as CachedItem<T>;
|
||||
if (persistentItem && this.isValid(persistentItem, now)) {
|
||||
persistentItem.accessCount++;
|
||||
// Promouvoir vers le cache mémoire
|
||||
this.setMemory(key, persistentItem.data, persistentItem.ttl);
|
||||
return persistentItem.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si un élément est valide
|
||||
private isValid(item: CachedItem, now: number): boolean {
|
||||
if (item.ttl && now - item.timestamp > item.ttl) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nettoyer le cache mémoire
|
||||
private cleanupMemory() {
|
||||
if (this.memoryCache.size <= this.maxMemoryItems) return;
|
||||
|
||||
// Trier par accessCount décroissant (LRU)
|
||||
const entries = Array.from(this.memoryCache.entries());
|
||||
entries.sort((a, b) => b[1].accessCount - a[1].accessCount);
|
||||
|
||||
// Garder seulement les plus utilisés
|
||||
const toKeep = entries.slice(0, this.maxMemoryItems);
|
||||
this.memoryCache.clear();
|
||||
|
||||
for (const [key, item] of toKeep) {
|
||||
this.memoryCache.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer le cache persistant
|
||||
private cleanupPersistent() {
|
||||
if (this.persistentCache.size <= this.maxPersistentItems) return;
|
||||
|
||||
// Trier par accessCount décroissant
|
||||
const entries = Array.from(this.persistentCache.entries());
|
||||
entries.sort((a, b) => b[1].accessCount - a[1].accessCount);
|
||||
|
||||
const toKeep = entries.slice(0, this.maxPersistentItems);
|
||||
this.persistentCache.clear();
|
||||
|
||||
for (const [key, item] of toKeep) {
|
||||
this.persistentCache.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer tous les caches
|
||||
cleanup() {
|
||||
this.cleanupMemory();
|
||||
this.cleanupPersistent();
|
||||
}
|
||||
|
||||
// Statistiques du cache
|
||||
getStats() {
|
||||
return {
|
||||
memory: {
|
||||
size: this.memoryCache.size,
|
||||
maxSize: this.maxMemoryItems
|
||||
},
|
||||
persistent: {
|
||||
size: this.persistentCache.size,
|
||||
maxSize: this.maxPersistentItems
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NotePreloaderService, NavigationContext } from './note-preloader.service';
|
||||
import { PaginationService } from './pagination.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NavigationService {
|
||||
private router = inject(Router);
|
||||
private preloader = inject(NotePreloaderService);
|
||||
private pagination = inject(PaginationService);
|
||||
|
||||
private navigationHistory: string[] = [];
|
||||
private readonly maxHistory = 20;
|
||||
|
||||
// Naviguer vers une note avec préchargement
|
||||
async navigateToNote(noteId: string) {
|
||||
// Ajouter à l'historique
|
||||
this.addToHistory(noteId);
|
||||
|
||||
// Créer le contexte de navigation
|
||||
const context: NavigationContext = {
|
||||
currentNoteId: noteId,
|
||||
recentNotes: [...this.navigationHistory],
|
||||
totalNotes: this.pagination.totalLoaded()
|
||||
};
|
||||
|
||||
// Démarrer le préchargement en arrière-plan
|
||||
this.preloader.preloadAdjacent(noteId, context);
|
||||
|
||||
// Naviguer
|
||||
await this.router.navigate(['/note', noteId]);
|
||||
}
|
||||
|
||||
// Obtenir le contexte actuel pour le préchargement
|
||||
getCurrentContext(noteId: string): NavigationContext {
|
||||
return {
|
||||
currentNoteId: noteId,
|
||||
recentNotes: [...this.navigationHistory],
|
||||
totalNotes: this.pagination.totalLoaded()
|
||||
};
|
||||
}
|
||||
|
||||
// Obtenir l'historique de navigation
|
||||
getHistory(): string[] {
|
||||
return [...this.navigationHistory];
|
||||
}
|
||||
|
||||
// Effacer l'historique
|
||||
clearHistory() {
|
||||
this.navigationHistory = [];
|
||||
}
|
||||
|
||||
private addToHistory(noteId: string) {
|
||||
// Éviter les doublons consécutifs
|
||||
if (this.navigationHistory[this.navigationHistory.length - 1] !== noteId) {
|
||||
this.navigationHistory.push(noteId);
|
||||
|
||||
// Garder seulement les derniers éléments
|
||||
if (this.navigationHistory.length > this.maxHistory) {
|
||||
this.navigationHistory.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ClientCacheService } from './client-cache.service';
|
||||
|
||||
export interface NoteContent {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
frontmatter: any;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface NavigationContext {
|
||||
currentNoteId: string;
|
||||
recentNotes: string[];
|
||||
totalNotes: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotePreloaderService {
|
||||
private http = inject(HttpClient);
|
||||
private cache = inject(ClientCacheService);
|
||||
|
||||
private preloadConfig = {
|
||||
enabled: true,
|
||||
maxConcurrentLoads: 3,
|
||||
preloadDistance: 2,
|
||||
cacheSize: 50
|
||||
};
|
||||
|
||||
private preloadQueue = new Map<string, Promise<NoteContent>>();
|
||||
private loadingNotes = new Set<string>();
|
||||
|
||||
// Précharger les notes adjacentes
|
||||
async preloadAdjacent(noteId: string, context: NavigationContext) {
|
||||
if (!this.preloadConfig.enabled) return;
|
||||
|
||||
const adjacentIds = this.getAdjacentNoteIds(noteId, context);
|
||||
|
||||
// Limiter le nombre de chargements simultanés
|
||||
const toPreload = adjacentIds.slice(0, this.preloadConfig.preloadDistance * 2);
|
||||
|
||||
for (const id of toPreload) {
|
||||
if (!this.preloadQueue.has(id) &&
|
||||
!this.loadingNotes.has(id) &&
|
||||
this.loadingNotes.size < this.preloadConfig.maxConcurrentLoads) {
|
||||
|
||||
this.loadingNotes.add(id);
|
||||
const preloadPromise = this.loadAndCacheNote(id);
|
||||
this.preloadQueue.set(id, preloadPromise);
|
||||
|
||||
// Nettoyer quand terminé
|
||||
preloadPromise.finally(() => {
|
||||
this.loadingNotes.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charger et mettre en cache une note
|
||||
private async loadAndCacheNote(noteId: string): Promise<NoteContent> {
|
||||
try {
|
||||
const cached = this.cache.get<NoteContent>(`note_${noteId}`);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<NoteContent>(`/api/files/${noteId}`)
|
||||
);
|
||||
|
||||
// Mettre en cache
|
||||
this.cache.setMemory(`note_${noteId}`, response, 30 * 60 * 1000);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.warn(`[Preloader] Failed to preload note ${noteId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les IDs des notes adjacentes
|
||||
private getAdjacentNoteIds(noteId: string, context: NavigationContext): string[] {
|
||||
const currentIndex = context.recentNotes.indexOf(noteId);
|
||||
if (currentIndex === -1) return [];
|
||||
|
||||
const adjacent: string[] = [];
|
||||
|
||||
// Notes précédentes
|
||||
for (let i = currentIndex - 1; i >= Math.max(0, currentIndex - this.preloadConfig.preloadDistance); i--) {
|
||||
adjacent.push(context.recentNotes[i]);
|
||||
}
|
||||
|
||||
// Notes suivantes
|
||||
for (let i = currentIndex + 1; i <= Math.min(context.recentNotes.length - 1, currentIndex + this.preloadConfig.preloadDistance); i++) {
|
||||
adjacent.push(context.recentNotes[i]);
|
||||
}
|
||||
|
||||
return adjacent;
|
||||
}
|
||||
|
||||
// Nettoyer le cache périodiquement
|
||||
cleanup() {
|
||||
this.cache.cleanup();
|
||||
// Nettoyer les promises échouées
|
||||
for (const [id, promise] of this.preloadQueue.entries()) {
|
||||
if (promise && typeof promise === 'object' && 'catch' in promise) {
|
||||
promise.catch(() => {
|
||||
this.preloadQueue.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir le statut du préchargement
|
||||
getStatus() {
|
||||
return {
|
||||
queueSize: this.preloadQueue.size,
|
||||
loadingCount: this.loadingNotes.size,
|
||||
config: this.preloadConfig
|
||||
};
|
||||
}
|
||||
|
||||
// Configurer le préchargement
|
||||
setConfig(config: Partial<typeof this.preloadConfig>) {
|
||||
this.preloadConfig = { ...this.preloadConfig, ...config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
interface PerformanceSample {
|
||||
duration: number;
|
||||
success: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface BottleneckAnalysis {
|
||||
slowOperations: Array<{
|
||||
operation: string;
|
||||
avgDuration: number;
|
||||
failureRate: number;
|
||||
}>;
|
||||
frequentOperations: Array<{
|
||||
operation: string;
|
||||
callCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PerformanceProfilerService {
|
||||
private metrics = new Map<string, PerformanceSample[]>();
|
||||
private readonly maxSamples = 100;
|
||||
|
||||
// Mesurer une opération asynchrone
|
||||
async measureAsync<T>(
|
||||
operationName: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await operation();
|
||||
const duration = performance.now() - start;
|
||||
this.recordSample(operationName, duration, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - start;
|
||||
this.recordSample(operationName, duration, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Mesurer une opération synchrone
|
||||
measureSync<T>(
|
||||
operationName: string,
|
||||
operation: () => T
|
||||
): T {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = operation();
|
||||
const duration = performance.now() - start;
|
||||
this.recordSample(operationName, duration, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - start;
|
||||
this.recordSample(operationName, duration, false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Analyser les goulots d'étranglement
|
||||
analyzeBottlenecks(): BottleneckAnalysis {
|
||||
const analysis: BottleneckAnalysis = {
|
||||
slowOperations: [],
|
||||
frequentOperations: []
|
||||
};
|
||||
|
||||
for (const [operation, samples] of this.metrics.entries()) {
|
||||
const avgDuration = samples.reduce((sum, s) => sum + s.duration, 0) / samples.length;
|
||||
const failureRate = samples.filter(s => !s.success).length / samples.length;
|
||||
|
||||
// Opérations lentes (> 100ms)
|
||||
if (avgDuration > 100) {
|
||||
analysis.slowOperations.push({
|
||||
operation,
|
||||
avgDuration: Math.round(avgDuration * 100) / 100,
|
||||
failureRate: Math.round(failureRate * 10000) / 100
|
||||
});
|
||||
}
|
||||
|
||||
// Opérations fréquentes (> 50 appels)
|
||||
if (samples.length > 50) {
|
||||
analysis.frequentOperations.push({
|
||||
operation,
|
||||
callCount: samples.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
// Obtenir les métriques brutes
|
||||
getMetrics() {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (const [operation, samples] of this.metrics.entries()) {
|
||||
const durations = samples.map(s => s.duration);
|
||||
const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const minDuration = Math.min(...durations);
|
||||
const maxDuration = Math.max(...durations);
|
||||
const p95Duration = this.percentile(durations, 95);
|
||||
|
||||
result[operation] = {
|
||||
sampleCount: samples.length,
|
||||
avgDuration: Math.round(avgDuration * 100) / 100,
|
||||
minDuration: Math.round(minDuration * 100) / 100,
|
||||
maxDuration: Math.round(maxDuration * 100) / 100,
|
||||
p95Duration: Math.round(p95Duration * 100) / 100,
|
||||
failureRate: samples.filter(s => !s.success).length / samples.length
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exporter les métriques pour analyse
|
||||
exportMetrics() {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
|
||||
metrics: this.getMetrics(),
|
||||
bottlenecks: this.analyzeBottlenecks(),
|
||||
memory: typeof performance !== 'undefined' && (performance as any).memory ? {
|
||||
used: (performance as any).memory.usedJSHeapSize,
|
||||
total: (performance as any).memory.totalJSHeapSize,
|
||||
limit: (performance as any).memory.jsHeapSizeLimit
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
private recordSample(operation: string, duration: number, success: boolean) {
|
||||
if (!this.metrics.has(operation)) {
|
||||
this.metrics.set(operation, []);
|
||||
}
|
||||
|
||||
const samples = this.metrics.get(operation)!;
|
||||
samples.push({
|
||||
duration,
|
||||
success,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Garder seulement les derniers échantillons
|
||||
if (samples.length > this.maxSamples) {
|
||||
samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private percentile(values: number[], p: number): number {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = (p / 100) * (sorted.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
|
||||
if (lower === upper) {
|
||||
return sorted[lower];
|
||||
}
|
||||
|
||||
return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower);
|
||||
}
|
||||
|
||||
// Réinitialiser les métriques
|
||||
reset() {
|
||||
this.metrics.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { ClientCacheService } from './client-cache.service';
|
||||
import { PerformanceProfilerService } from './performance-profiler.service';
|
||||
import { NotePreloaderService, NoteContent, NavigationContext } from './note-preloader.service';
|
||||
import { NavigationService } from './navigation.service';
|
||||
import { PaginationService } from './pagination.service';
|
||||
|
||||
describe('Phase 4 - Client-Side Optimizations', () => {
|
||||
|
||||
describe('ClientCacheService', () => {
|
||||
let service: ClientCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ClientCacheService]
|
||||
});
|
||||
service = TestBed.inject(ClientCacheService);
|
||||
});
|
||||
|
||||
it('should cache and retrieve items in memory', () => {
|
||||
const key = 'test_key';
|
||||
const value = { id: '1', title: 'Test' };
|
||||
|
||||
service.setMemory(key, value);
|
||||
const retrieved = service.get(key);
|
||||
|
||||
expect(retrieved).toEqual(value);
|
||||
});
|
||||
|
||||
it('should respect TTL expiration', (done) => {
|
||||
const key = 'ttl_test';
|
||||
const value = { id: '1' };
|
||||
const ttlMs = 100;
|
||||
|
||||
service.setMemory(key, value, ttlMs);
|
||||
expect(service.get(key)).toEqual(value);
|
||||
|
||||
setTimeout(() => {
|
||||
expect(service.get(key)).toBeNull();
|
||||
done();
|
||||
}, ttlMs + 50);
|
||||
});
|
||||
|
||||
it('should implement LRU eviction', () => {
|
||||
// Fill cache beyond max size
|
||||
for (let i = 0; i < 60; i++) {
|
||||
service.setMemory(`key_${i}`, { id: i });
|
||||
}
|
||||
|
||||
const stats = service.getStats();
|
||||
expect(stats.memory.size).toBeLessThanOrEqual(stats.memory.maxSize);
|
||||
});
|
||||
|
||||
it('should promote items from persistent to memory cache', () => {
|
||||
const key = 'promote_test';
|
||||
const value = { id: '1' };
|
||||
|
||||
service.setPersistent(key, value);
|
||||
expect(service.get(key)).toEqual(value);
|
||||
|
||||
// Should now be in memory cache
|
||||
const stats = service.getStats();
|
||||
expect(stats.memory.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should track access count for LRU', () => {
|
||||
const key = 'access_test';
|
||||
const value = { id: '1' };
|
||||
|
||||
service.setMemory(key, value);
|
||||
service.get(key);
|
||||
service.get(key);
|
||||
service.get(key);
|
||||
|
||||
// Access count should increase
|
||||
expect(service.get(key)).toEqual(value);
|
||||
});
|
||||
|
||||
it('should cleanup expired items', () => {
|
||||
service.setMemory('key1', { id: '1' }, 50);
|
||||
service.setMemory('key2', { id: '2' }, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
service.cleanup();
|
||||
expect(service.get('key1')).toBeNull();
|
||||
expect(service.get('key2')).toBeNull();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PerformanceProfilerService', () => {
|
||||
let service: PerformanceProfilerService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [PerformanceProfilerService]
|
||||
});
|
||||
service = TestBed.inject(PerformanceProfilerService);
|
||||
});
|
||||
|
||||
it('should measure async operations', async () => {
|
||||
const result = await service.measureAsync('test_async', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(result).toBe('result');
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics['test_async']).toBeDefined();
|
||||
expect(metrics['test_async'].avgDuration).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should measure sync operations', () => {
|
||||
const result = service.measureSync('test_sync', () => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
sum += i;
|
||||
}
|
||||
return sum;
|
||||
});
|
||||
|
||||
expect(result).toBe(499500);
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics['test_sync']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should track failures', async () => {
|
||||
try {
|
||||
await service.measureAsync('test_failure', async () => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
} catch (e) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics['test_failure'].failureRate).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should analyze bottlenecks', async () => {
|
||||
// Create slow operations
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await service.measureAsync('slow_op', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
});
|
||||
}
|
||||
|
||||
const bottlenecks = service.analyzeBottlenecks();
|
||||
expect(bottlenecks.slowOperations.length).toBeGreaterThan(0);
|
||||
expect(bottlenecks.slowOperations[0].operation).toBe('slow_op');
|
||||
});
|
||||
|
||||
it('should calculate percentiles', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.measureAsync('percentile_test', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10 + i * 5));
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = service.getMetrics();
|
||||
expect(metrics['percentile_test'].p95Duration).toBeGreaterThan(metrics['percentile_test'].avgDuration);
|
||||
});
|
||||
|
||||
it('should export metrics', () => {
|
||||
service.measureSync('export_test', () => 42);
|
||||
|
||||
const exported = service.exportMetrics();
|
||||
expect(exported.timestamp).toBeDefined();
|
||||
expect(exported.userAgent).toBeDefined();
|
||||
expect(exported.metrics).toBeDefined();
|
||||
expect(exported.bottlenecks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reset metrics', () => {
|
||||
service.measureSync('reset_test', () => 42);
|
||||
let metrics = service.getMetrics();
|
||||
expect(Object.keys(metrics).length).toBeGreaterThan(0);
|
||||
|
||||
service.reset();
|
||||
metrics = service.getMetrics();
|
||||
expect(Object.keys(metrics).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotePreloaderService', () => {
|
||||
let service: NotePreloaderService;
|
||||
let httpMock: HttpTestingController;
|
||||
let cache: ClientCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [NotePreloaderService, ClientCacheService]
|
||||
});
|
||||
service = TestBed.inject(NotePreloaderService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
cache = TestBed.inject(ClientCacheService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should preload adjacent notes', async () => {
|
||||
const context: NavigationContext = {
|
||||
currentNoteId: 'note_2',
|
||||
recentNotes: ['note_1', 'note_2', 'note_3', 'note_4'],
|
||||
totalNotes: 4
|
||||
};
|
||||
|
||||
service.preloadAdjacent('note_2', context);
|
||||
|
||||
// Should attempt to load adjacent notes
|
||||
const status = service.getStatus();
|
||||
expect(status.queueSize).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should respect concurrent load limits', async () => {
|
||||
const context: NavigationContext = {
|
||||
currentNoteId: 'note_1',
|
||||
recentNotes: ['note_1', 'note_2', 'note_3', 'note_4', 'note_5'],
|
||||
totalNotes: 5
|
||||
};
|
||||
|
||||
service.preloadAdjacent('note_1', context);
|
||||
|
||||
const status = service.getStatus();
|
||||
expect(status.loadingCount).toBeLessThanOrEqual(status.config.maxConcurrentLoads);
|
||||
});
|
||||
|
||||
it('should use cache for preloaded notes', async () => {
|
||||
const noteId = 'cached_note';
|
||||
const noteContent: NoteContent = {
|
||||
id: noteId,
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
frontmatter: {},
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
cache.setMemory(`note_${noteId}`, noteContent);
|
||||
|
||||
const context: NavigationContext = {
|
||||
currentNoteId: noteId,
|
||||
recentNotes: [noteId],
|
||||
totalNotes: 1
|
||||
};
|
||||
|
||||
await service.preloadAdjacent(noteId, context);
|
||||
|
||||
// Should retrieve from cache
|
||||
const cached = cache.get(`note_${noteId}`);
|
||||
expect(cached).toEqual(noteContent);
|
||||
});
|
||||
|
||||
it('should configure preload settings', () => {
|
||||
service.setConfig({ preloadDistance: 3, maxConcurrentLoads: 5 });
|
||||
const status = service.getStatus();
|
||||
|
||||
expect(status.config.preloadDistance).toBe(3);
|
||||
expect(status.config.maxConcurrentLoads).toBe(5);
|
||||
});
|
||||
|
||||
it('should cleanup resources', () => {
|
||||
service.cleanup();
|
||||
const status = service.getStatus();
|
||||
|
||||
// Should have cleaned up
|
||||
expect(status.queueSize).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavigationService', () => {
|
||||
let service: NavigationService;
|
||||
let preloader: NotePreloaderService;
|
||||
let pagination: PaginationService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [NavigationService, NotePreloaderService, PaginationService, ClientCacheService]
|
||||
});
|
||||
service = TestBed.inject(NavigationService);
|
||||
preloader = TestBed.inject(NotePreloaderService);
|
||||
pagination = TestBed.inject(PaginationService);
|
||||
});
|
||||
|
||||
it('should track navigation history', () => {
|
||||
service.getCurrentContext('note_1');
|
||||
service.getCurrentContext('note_2');
|
||||
service.getCurrentContext('note_3');
|
||||
|
||||
const history = service.getHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should avoid duplicate consecutive entries', () => {
|
||||
service.getCurrentContext('note_1');
|
||||
service.getCurrentContext('note_1');
|
||||
service.getCurrentContext('note_1');
|
||||
|
||||
const history = service.getHistory();
|
||||
expect(history.filter(id => id === 'note_1').length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should create navigation context', () => {
|
||||
const context = service.getCurrentContext('note_1');
|
||||
|
||||
expect(context.currentNoteId).toBe('note_1');
|
||||
expect(context.recentNotes).toBeDefined();
|
||||
expect(context.totalNotes).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should clear history', () => {
|
||||
service.getCurrentContext('note_1');
|
||||
service.getCurrentContext('note_2');
|
||||
|
||||
service.clearHistory();
|
||||
const history = service.getHistory();
|
||||
|
||||
expect(history.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
let cache: ClientCacheService;
|
||||
let profiler: PerformanceProfilerService;
|
||||
let preloader: NotePreloaderService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [ClientCacheService, PerformanceProfilerService, NotePreloaderService]
|
||||
});
|
||||
cache = TestBed.inject(ClientCacheService);
|
||||
profiler = TestBed.inject(PerformanceProfilerService);
|
||||
preloader = TestBed.inject(NotePreloaderService);
|
||||
});
|
||||
|
||||
it('should handle cache + profiling together', async () => {
|
||||
const result = await profiler.measureAsync('cache_test', async () => {
|
||||
const key = 'integration_test';
|
||||
const value = { id: '1', data: 'test' };
|
||||
|
||||
cache.setMemory(key, value);
|
||||
return cache.get(key);
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
const metrics = profiler.getMetrics();
|
||||
expect(metrics['cache_test']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should maintain performance under load', async () => {
|
||||
const operations = [];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
operations.push(
|
||||
profiler.measureAsync(`load_test_${i}`, async () => {
|
||||
const key = `load_${i}`;
|
||||
cache.setMemory(key, { id: i });
|
||||
return cache.get(key);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const metrics = profiler.getMetrics();
|
||||
const avgDurations = Object.values(metrics).map((m: any) => m.avgDuration);
|
||||
const maxAvg = Math.max(...avgDurations);
|
||||
|
||||
expect(maxAvg).toBeLessThan(100); // Should be fast
|
||||
});
|
||||
|
||||
it('should not leak memory', () => {
|
||||
const initialStats = cache.getStats();
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
cache.setMemory(`leak_test_${i}`, { id: i });
|
||||
}
|
||||
|
||||
const finalStats = cache.getStats();
|
||||
|
||||
// Should not exceed max size
|
||||
expect(finalStats.memory.size).toBeLessThanOrEqual(finalStats.memory.maxSize);
|
||||
expect(finalStats.persistent.size).toBeLessThanOrEqual(finalStats.persistent.maxSize);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,255 +50,271 @@ export interface WikiLinkActivation {
|
||||
template: `
|
||||
<div class="relative p-1 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]">
|
||||
<div class="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
|
||||
<!-- Compact Top Bar -->
|
||||
<div class="flex items-start justify-between gap-2 pl-1 pr-2 py-1 mb-2 text-text-muted text-xs">
|
||||
<app-note-header class="flex-1 min-w-0"
|
||||
[fullPath]="note().filePath"
|
||||
[noteId]="note().id"
|
||||
[tags]="note().tags ?? []"
|
||||
(copyRequested)="copyPath()"
|
||||
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note().filePath))"
|
||||
(tagsChange)="onTagsChange($event)"
|
||||
(tagSelected)="tagClicked.emit($event)"
|
||||
></app-note-header>
|
||||
|
||||
<div class="flex items-center gap-1 self-start pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="enterEdit()"
|
||||
title="Éditer"
|
||||
aria-label="Éditer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="fullScreenRequested.emit()"
|
||||
[attr.title]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
[attr.aria-label]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
>
|
||||
{{ fullScreenActive() ? '⤢' : '⤢' }}
|
||||
</button>
|
||||
<ng-container *ngIf="note() as note">
|
||||
<!-- Compact Top Bar -->
|
||||
<div class="flex items-start justify-between gap-2 pl-1 pr-2 py-1 mb-2 text-text-muted text-xs">
|
||||
<app-note-header class="flex-1 min-w-0"
|
||||
[fullPath]="note.filePath"
|
||||
[noteId]="note.id"
|
||||
[tags]="note.tags ?? []"
|
||||
(copyRequested)="copyPath()"
|
||||
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
|
||||
(tagsChange)="onTagsChange($event)"
|
||||
(tagSelected)="tagClicked.emit($event)"
|
||||
></app-note-header>
|
||||
|
||||
<div class="hidden sm:block">
|
||||
<div class="flex items-center gap-1 self-start pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="showToc.emit()"
|
||||
[attr.title]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
[attr.aria-label]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
(click)="enterEdit()"
|
||||
title="Éditer"
|
||||
aria-label="Éditer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [ngClass]="tocOpen() ? 'toc-toggle--active' : 'toc-toggle--idle'"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M13 8h5"/><path d="M13 12h5"/><path d="M13 16h5"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="copyMarkdown()"
|
||||
aria-label="Copier tout le markdown"
|
||||
title="Copier tout le markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M20 7v11a2 2 0 0 1-2 2H9"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="toggleMenu()"
|
||||
aria-haspopup="menu"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
(click)="fullScreenRequested.emit()"
|
||||
[attr.title]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
[attr.aria-label]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||
>
|
||||
⋯
|
||||
{{ fullScreenActive() ? '⤢' : '⤢' }}
|
||||
</button>
|
||||
@if (menuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-56 rounded-xl border border-border bg-card shadow-subtle not-prose z-10 overflow-hidden">
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="parametersRequested.emit(); closeMenu()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6m-6-6h6m6 0h-6M4.93 4.93l4.24 4.24m5.66 5.66l4.24 4.24m-4.24-14.14l4.24-4.24M4.93 19.07l4.24-4.24"/>
|
||||
</svg>
|
||||
Parameters
|
||||
</button>
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="legacyRequested.emit(); closeMenu()">🔧 Legacy</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEditMode()) {
|
||||
<app-markdown-editor
|
||||
[initialPath]="note().filePath"
|
||||
[initialContent]="note().rawContent ?? note().content"
|
||||
/>
|
||||
} @else {
|
||||
<div class="hidden sm:block">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="showToc.emit()"
|
||||
[attr.title]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
[attr.aria-label]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [ngClass]="tocOpen() ? 'toc-toggle--active' : 'toc-toggle--idle'"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M13 8h5"/><path d="M13 12h5"/><path d="M13 16h5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (frontmatterTags().length > 0) {
|
||||
<div class="mb-6 md-tag-group not-prose">
|
||||
@for (tag of frontmatterTags(); track tag) {
|
||||
<button
|
||||
type="button"
|
||||
class="md-tag-badge"
|
||||
[ngClass]="tagColorClass(tag)"
|
||||
data-origin="frontmatter"
|
||||
[attr.data-tag]="tag"
|
||||
(click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
class="note-toolbar-icon"
|
||||
(click)="searchRequested.emit()"
|
||||
title="Rechercher"
|
||||
aria-label="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="copyMarkdown()"
|
||||
aria-label="Copier tout le markdown"
|
||||
title="Copier tout le markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M20 7v11a2 2 0 0 1-2 2H9"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="toggleMenu()"
|
||||
aria-haspopup="menu"
|
||||
[attr.aria-expanded]="menuOpen()"
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
@if (menuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-56 rounded-xl border border-border bg-card shadow-subtle not-prose z-10 overflow-hidden">
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="parametersRequested.emit(); closeMenu()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-4 w-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v6m0 6v6m-6-6h6m6 0h-6M4.93 4.93l4.24 4.24m5.66 5.66l4.24 4.24m-4.24-14.14l4.24-4.24M4.93 19.07l4.24-4.24"/>
|
||||
</svg>
|
||||
Parameters
|
||||
</button>
|
||||
<button type="button" class="block w-full text-left px-3 py-2 text-sm transition-colors hover:bg-[rgba(99,102,241,0.12)] dark:hover:bg-[rgba(99,102,241,0.28)] focus-visible:bg-[rgba(99,102,241,0.16)] dark:focus-visible:bg-[rgba(99,102,241,0.35)]" (click)="legacyRequested.emit(); closeMenu()">🔧 Legacy</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEditMode()) {
|
||||
<app-markdown-editor
|
||||
[initialPath]="note.filePath"
|
||||
[initialContent]="note.rawContent ?? note.content"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@if (frontmatterTags().length > 0) {
|
||||
<div class="mb-6 md-tag-group not-prose">
|
||||
@for (tag of frontmatterTags(); track tag) {
|
||||
<button
|
||||
type="button"
|
||||
class="md-tag-badge"
|
||||
[ngClass]="tagColorClass(tag)"
|
||||
data-origin="frontmatter"
|
||||
[attr.data-tag]="tag"
|
||||
(click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="not-prose flex flex-col gap-2 text-sm text-text-muted my-4">
|
||||
<!-- Row 1: date + author -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ note.updatedAt | date:'medium' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" />
|
||||
</svg>
|
||||
{{ getAuthorFromFrontmatter() ?? note.author ?? 'Auteur inconnu' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: state icons (toggle buttons) -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if (hasState('favoris')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')">
|
||||
@if (state('favoris')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="not-prose flex flex-col gap-2 text-sm text-text-muted my-4">
|
||||
<!-- Row 1: date + author -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ note().updatedAt | date:'medium' }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0" />
|
||||
</svg>
|
||||
{{ getAuthorFromFrontmatter() ?? note().author ?? 'Auteur inconnu' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: state icons (toggle buttons) -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
@if (hasState('favoris')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-muted'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" (click)="toggleState('favoris')">
|
||||
@if (state('favoris')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
</svg>
|
||||
} @else {
|
||||
@if (hasState('publish')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('publish') ? 'text-green-500' : 'text-muted'" title="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" aria-label="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" (click)="toggleState('publish')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('publish')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('publish') ? 'text-green-500' : 'text-muted'" title="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" aria-label="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" (click)="toggleState('publish')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
@if (hasState('draft')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('draft') ? 'text-yellow-500' : 'text-muted'" title="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" aria-label="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" (click)="toggleState('draft')">
|
||||
@if (state('draft')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v4H3z" />
|
||||
<path d="M6 7v14h12V7" />
|
||||
<path d="M9 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('template')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('template') ? 'text-amber-500' : 'text-muted'" title="{{ state('template') ? 'Modèle' : 'Non modèle' }}" aria-label="{{ state('template') ? 'Modèle' : 'Non modèle' }}" (click)="toggleState('template')">
|
||||
@if (state('template')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="14" rx="2" />
|
||||
<path d="M7 8h10" />
|
||||
<path d="M7 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="5" width="18" height="12" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('task')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('task') ? 'text-indigo-500' : 'text-muted'" title="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" aria-label="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" (click)="toggleState('task')">
|
||||
@if (state('task')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
<path d="M8 12l2 2 4-4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('private')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('private') ? 'text-purple-500' : 'text-muted'" title="{{ state('private') ? 'Privé' : 'Public' }}" aria-label="{{ state('private') ? 'Privé' : 'Public' }}" (click)="toggleState('private')">
|
||||
@if (state('private')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('archive')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('archive') ? 'text-amber-600' : 'text-muted'" title="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" aria-label="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" (click)="toggleState('archive')">
|
||||
@if (state('archive')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" 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="22,12 18,12 18,8"/>
|
||||
<path d="M18,2H6a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8Z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8v13a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/>
|
||||
<path d="M3 4h18l-2-2H5l-2 2z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div [innerHTML]="sanitizedHtmlContent()"></div>
|
||||
|
||||
@if (note().backlinks.length > 0) {
|
||||
<div class="mt-12 pt-6 border-t border-border not-prose">
|
||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||
<ul>
|
||||
@for (backlinkId of note().backlinks; track backlinkId) {
|
||||
<li class="mb-2">
|
||||
<a
|
||||
(click)="noteLinkClicked.emit(backlinkId)"
|
||||
class="cursor-pointer text-accent hover:underline">
|
||||
{{ formatBacklinkId(backlinkId) }}
|
||||
</a>
|
||||
</li>
|
||||
@if (hasState('draft')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('draft') ? 'text-yellow-500' : 'text-muted'" title="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" aria-label="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" (click)="toggleState('draft')">
|
||||
@if (state('draft')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3h18v4H3z" />
|
||||
<path d="M6 7v14h12V7" />
|
||||
<path d="M9 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('template')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('template') ? 'text-amber-500' : 'text-muted'" title="{{ state('template') ? 'Modèle' : 'Non modèle' }}" aria-label="{{ state('template') ? 'Modèle' : 'Non modèle' }}" (click)="toggleState('template')">
|
||||
@if (state('template')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="14" rx="2" />
|
||||
<path d="M7 8h10" />
|
||||
<path d="M7 12h6" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="5" width="18" height="12" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('task')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('task') ? 'text-indigo-500' : 'text-muted'" title="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" aria-label="{{ state('task') ? 'Tâche' : 'Pas une tâche' }}" (click)="toggleState('task')">
|
||||
@if (state('task')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
<path d="M8 12l2 2 4-4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="4" width="18" height="16" rx="2" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('private')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('private') ? 'text-purple-500' : 'text-muted'" title="{{ state('private') ? 'Privé' : 'Public' }}" aria-label="{{ state('private') ? 'Privé' : 'Public' }}" (click)="toggleState('private')">
|
||||
@if (state('private')) {
|
||||
<svg xmlns="http://www.w3.org/2000/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="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/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="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (hasState('archive')) {
|
||||
<button type="button" class="inline-flex items-center gap-1 transition-colors focus:outline-none" [ngClass]="state('archive') ? 'text-amber-600' : 'text-muted'" title="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" aria-label="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" (click)="toggleState('archive')">
|
||||
@if (state('archive')) {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" 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="22,12 18,12 18,8"/>
|
||||
<path d="M18,2H6a2,2,0,0,0-2,2V22a2,2,0,0,0,2,2H18a2,2,0,0,0,2-2V8Z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 8v13a2 2 0 01-2 2H5a2 2 0 01-2-2V8"/>
|
||||
<path d="M3 4h18l-2-2H5l-2 2z"/>
|
||||
<line x1="10" y1="12" x2="14" y2="12"/>
|
||||
</svg>
|
||||
}
|
||||
</ul>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
<div [innerHTML]="sanitizedHtmlContent()"></div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="note() as backlinksNote">
|
||||
@if (backlinksNote.backlinks.length > 0) {
|
||||
<div class="mt-12 pt-6 border-t border-border not-prose">
|
||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||
<ul>
|
||||
@for (backlinkId of backlinksNote.backlinks; track backlinkId) {
|
||||
<li class="mb-2">
|
||||
<a
|
||||
(click)="noteLinkClicked.emit(backlinkId)"
|
||||
class="cursor-pointer text-accent hover:underline">
|
||||
{{ formatBacklinkId(backlinkId) }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -959,8 +975,10 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
|
||||
hasState(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task'): boolean {
|
||||
try {
|
||||
const fm = (this.note()?.frontmatter ?? {}) as any;
|
||||
const raw = (fm as any)[key];
|
||||
const note = this.note();
|
||||
if (!note?.frontmatter) return false;
|
||||
const fm = note.frontmatter as any;
|
||||
const raw = fm[key];
|
||||
return raw !== undefined && raw !== null;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -969,8 +987,10 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
|
||||
state(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task'): boolean {
|
||||
try {
|
||||
const fm = (this.note()?.frontmatter ?? {}) as any;
|
||||
const raw = (fm as any)[key];
|
||||
const note = this.note();
|
||||
if (!note?.frontmatter) return false;
|
||||
const fm = note.frontmatter as any;
|
||||
const raw = fm[key];
|
||||
return this.toBoolean(raw);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -136,30 +136,6 @@ export class VaultService implements OnDestroy {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
async updateNoteStates(
|
||||
noteId: string,
|
||||
key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task',
|
||||
nextValue: boolean
|
||||
): Promise<boolean> {
|
||||
const note = this.getNoteById(noteId);
|
||||
if (!note?.filePath) return false;
|
||||
|
||||
const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
|
||||
const updatedRaw = rewriteBooleanFrontmatter(currentRaw, { [key]: nextValue });
|
||||
|
||||
if (!await this.saveMarkdown(note.filePath, updatedRaw)) return false;
|
||||
|
||||
const updatedFrontmatter = { ...(note.frontmatter || {}) } as any;
|
||||
if (nextValue === undefined as any) {
|
||||
delete updatedFrontmatter[key];
|
||||
} else {
|
||||
updatedFrontmatter[key] = nextValue;
|
||||
}
|
||||
|
||||
this.updateNoteInMap(note, { rawContent: updatedRaw, frontmatter: updatedFrontmatter });
|
||||
return true;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
@@ -1432,4 +1408,22 @@ export class VaultService implements OnDestroy {
|
||||
return note.content ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
async updateNoteStates(noteId: string, key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task', next: boolean): Promise<boolean> {
|
||||
const note = this.getNoteById(noteId);
|
||||
if (!note?.filePath) return false;
|
||||
|
||||
const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
|
||||
const updatedRaw = rewriteBooleanFrontmatter(currentRaw, { [key]: next });
|
||||
|
||||
if (!await this.saveMarkdown(note.filePath, updatedRaw)) return false;
|
||||
|
||||
// Update the note in the map
|
||||
this.updateNoteInMap(note, { rawContent: updatedRaw, frontmatter: { ...note.frontmatter, [key]: next } });
|
||||
|
||||
// Refresh to update computed values
|
||||
this.refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user