From 6e50acbd224106646abb39092e123b7b5f35ac59 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 21 Oct 2025 11:41:39 -0400 Subject: [PATCH] feat: add document properties popover with frontmatter metadata --- PROPERTIES_POPOVER_IMPLEMENTATION.md | 227 ++++++++++++++ .../note-header/note-header.component.html | 22 ++ .../note-header/note-header.component.ts | 76 ++++- .../properties-popover.component.html | 71 +++++ .../properties-popover.component.ts | 100 ++++++ .../state-chip/state-chip.component.html | 14 + .../state-chip/state-chip.component.ts | 31 ++ .../shared/frontmatter-properties.service.ts | 242 +++++++++++++++ .../note/shared/note-properties.model.ts | 30 ++ .../note-viewer/note-viewer.component.html | 14 - .../note-viewer/note-viewer.component.ts | 291 +----------------- vault/Nouveau-markdown.md | 18 +- vault/Nouveau-markdown.md.bak | 24 +- vault/folder1/test2.md | 11 +- vault/folder1/test2.md.bak | 16 +- vault/test.md | 3 +- vault/test.md.bak | 2 +- 17 files changed, 853 insertions(+), 339 deletions(-) create mode 100644 PROPERTIES_POPOVER_IMPLEMENTATION.md create mode 100644 src/app/features/note/components/properties-popover/properties-popover.component.html create mode 100644 src/app/features/note/components/properties-popover/properties-popover.component.ts create mode 100644 src/app/features/note/components/state-chip/state-chip.component.html create mode 100644 src/app/features/note/components/state-chip/state-chip.component.ts create mode 100644 src/app/features/note/shared/frontmatter-properties.service.ts create mode 100644 src/app/features/note/shared/note-properties.model.ts diff --git a/PROPERTIES_POPOVER_IMPLEMENTATION.md b/PROPERTIES_POPOVER_IMPLEMENTATION.md new file mode 100644 index 0000000..05a0e2d --- /dev/null +++ b/PROPERTIES_POPOVER_IMPLEMENTATION.md @@ -0,0 +1,227 @@ +# Implémentation de la fenêtre flottante des propriétés + +## ✅ Modifications effectuées + +### 1. Composant note-header (src/app/features/note/components/note-header/) + +**Fichier TypeScript (`note-header.component.ts`):** +- ✅ Ajout des imports nécessaires: `Overlay`, `OverlayRef`, `ComponentPortal`, `PropertiesPopoverComponent`, `FrontmatterPropertiesService`, `VaultService` +- ✅ Ajout des propriétés pour gérer l'overlay: + - `popoverOpen`: booléen pour l'état d'ouverture + - `overlayRef`: référence à l'overlay CDK + - `closeTimer`: timer pour fermeture différée +- ✅ Injection des services nécessaires via `inject()` +- ✅ Méthodes ajoutées: + - `openPopover(origin: HTMLElement)`: ouvre le popover avec positionnement flexible + - `scheduleClose()`: fermeture différée (150ms) + - `togglePopover(origin: HTMLElement)`: bascule l'état + - `closePopover()`: ferme et nettoie l'overlay +- ✅ Nettoyage dans `ngOnDestroy()` + +**Fichier HTML (`note-header.component.html`):** +- ✅ Ajout du bouton avec icône `list-tree` (SVG inline) +- ✅ Positionnement: juste après le bouton "Copier le chemin" +- ✅ Gestion des événements: + - `mouseenter` / `mouseleave`: hover desktop + - `focusin` / `focusout`: navigation clavier + - `click`: toggle mobile/desktop +- ✅ Attributs ARIA: + - `aria-label="Afficher les propriétés du document"` + - `aria-haspopup="dialog"` + - `[attr.aria-expanded]` dynamique + - `[attr.aria-controls]` conditionnel + +### 2. Composants existants réutilisés + +**PropertiesPopoverComponent** (`src/app/features/note/components/properties-popover/`) +- ✅ Déjà existant et fonctionnel +- ✅ Affiche toutes les propriétés du frontmatter YAML +- ✅ Sections: résumé, tags, aliases, états, propriétés additionnelles +- ✅ Empty state: "Aucune propriété détectée." +- ✅ Thème-aware avec classes Tailwind + +**StateChipComponent** (`src/app/features/note/components/state-chip/`) +- ✅ Déjà existant +- ✅ Affiche les états avec icônes appropriées +- ✅ Gère: publish, favoris, archive, draft, private + +**FrontmatterPropertiesService** (`src/app/features/note/shared/`) +- ✅ Service déjà implémenté +- ✅ Parse le YAML du frontmatter +- ✅ Normalise les propriétés +- ✅ Cache par noteId + timestamp +- ✅ Gère toutes les propriétés YAML disponibles + +### 3. Suppression de l'ancienne section + +**note-viewer.component.html:** +- ✅ Supprimé la section "Métadonnées" (lignes 16-28) +- ✅ Supprimé l'affichage en grille des propriétés frontmatter + +**note-viewer.component.ts:** +- ✅ Supprimé la section `metadata-panel` du template inline (lignes 179-216) +- ✅ Supprimé les propriétés: + - `metadataExpanded` + - `maxMetadataPreviewItems` + - `metadataKeysToExclude` + - `dateFormatter` (utilisé uniquement pour metadata) +- ✅ Supprimé les computed signals: + - `metadataEntries` + - `metadataVisibleEntries` + - `metadataListId` +- ✅ Supprimé les méthodes: + - `toggleMetadataPanel()` + - `buildMetadataEntry()` + - `formatMetadataLabel()` + - `coerceToString()` → remplacé par `String()` dans `getAuthorFromFrontmatter()` + - `looksLikeEmail()` + - `looksLikeUrl()` + - `ensureUrlProtocol()` + - `looksLikeImageUrl()` + - `tryParseDate()` + - `isBooleanLike()` + - `translate()` + - `shouldSkipMetadataKey()` + - `slugify()` + - `getFrontmatterKeys()` +- ✅ Supprimé les types/interfaces: + - `MetadataEntryType` + - `MetadataEntry` + +## 🎨 Fonctionnalités + +### Déclenchement du popover +- **Desktop**: hover sur l'icône (mouseenter/mouseleave) +- **Clavier**: focus sur le bouton (focusin/focusout) +- **Mobile/Touch**: tap/click pour toggle + +### Positionnement +- Position préférée: à droite de l'icône (8px offset) +- Position fallback: au-dessus de l'icône (8px offset) +- Repositionnement automatique avec `scrollStrategy` + +### Contenu affiché +Le popover affiche **toutes** les propriétés YAML disponibles: +1. **Résumé** (si présent): + - Titre + - Auteur + - Date de création + - Date de modification + - Catégorie + +2. **Tags** (badges avec border) + +3. **Aliases** (séparés par " · ") + +4. **États** (avec icônes): + - Publié (globe) + - Favori (heart) + - Archivé (archive/box) + - Brouillon (file) + - Privé (lock) + +5. **Propriétés additionnelles**: + - Toutes les autres clés YAML non consommées + - Affichage avec label humanisé + - Support multiline pour texte long + +### Thèmes +- ✅ Classes Tailwind theme-aware: + - `bg-popover` + - `text-popover-foreground` + - `border-border` +- ✅ Compatible thème clair/sombre +- ✅ Compatible thèmes personnalisés + +### Accessibilité +- ✅ Navigation clavier complète +- ✅ Attributs ARIA appropriés +- ✅ Focus visible +- ✅ Rôle `dialog` sur le popover + +## 🧪 Tests à effectuer + +### Tests fonctionnels +- [ ] L'icône list-tree est visible dans le header +- [ ] L'icône est positionnée juste après le bouton "Copier" +- [ ] Hover desktop ouvre le popover +- [ ] Quitter le hover ferme le popover (avec délai) +- [ ] Click/tap toggle le popover +- [ ] Focus clavier ouvre le popover +- [ ] Blur clavier ferme le popover +- [ ] Click extérieur ferme le popover + +### Tests de contenu +- [ ] Note avec toutes les propriétés → affichage complet +- [ ] Note sans frontmatter → "Aucune propriété détectée." +- [ ] Note avec propriétés partielles → seules les présentes sont affichées +- [ ] Tags nombreux → wrap correct sans overflow +- [ ] Propriétés additionnelles → affichage avec label humanisé +- [ ] États booléens → icônes et labels corrects +- [ ] Archive true → icône 🗃️ "Archivé" +- [ ] Archive false → icône 📋 "Non archivé" + +### Tests thèmes +- [ ] Thème clair → lisible et cohérent +- [ ] Thème sombre → lisible et cohérent +- [ ] Thèmes personnalisés → couleurs héritées + +### Tests responsive +- [ ] Desktop (>1024px) → hover fonctionne +- [ ] Tablet (768-1024px) → tap fonctionne +- [ ] Mobile (<768px) → tap fonctionne, taille adaptée + +### Tests accessibilité +- [ ] Tab pour focus le bouton +- [ ] Enter/Space ouvre le popover +- [ ] Escape ferme le popover +- [ ] Lecteur d'écran annonce le bouton et son état +- [ ] Lecteur d'écran lit le contenu du popover + +### Tests performance +- [ ] Première ouverture → parsing YAML +- [ ] Ouvertures suivantes → cache utilisé +- [ ] Changement de note → cache invalidé +- [ ] Pas de fuite mémoire après fermeture + +## 📝 Notes techniques + +### Pourquoi pas lucide-angular ? +- Lucide-angular n'est pas installé dans le projet +- Utilisation de SVG inline cohérente avec le reste du code +- Évite une dépendance supplémentaire + +### Gestion du cache +Le service `FrontmatterPropertiesService` cache les propriétés par: +- `noteId` (clé primaire) +- `timestamp` (updatedAt ou mtime) + +Le cache est automatiquement invalidé quand la note change. + +### CDK Overlay +Configuration utilisée: +```typescript +{ + positionStrategy: flexibleConnectedTo(origin) + .withPositions([right-start, top-start]) + .withPush(true), + hasBackdrop: false, + scrollStrategy: reposition() +} +``` + +### Fermeture différée +Délai de 150ms pour permettre le passage de la souris entre le bouton et le popover sans fermeture intempestive. + +## 🚀 Prochaines étapes + +1. Tester manuellement toutes les fonctionnalités +2. Vérifier les thèmes clair/sombre +3. Tester sur mobile/tablet +4. Valider l'accessibilité clavier +5. Vérifier les performances (cache) +6. Créer des tests E2E si nécessaire + +## ✨ Résultat + +L'utilisateur dispose maintenant d'une interface moderne et accessible pour consulter toutes les propriétés YAML d'une note via une fenêtre flottante élégante, sans encombrer l'interface principale. diff --git a/src/app/features/note/components/note-header/note-header.component.html b/src/app/features/note/components/note-header/note-header.component.html index 8c49e88..a5140fc 100644 --- a/src/app/features/note/components/note-header/note-header.component.html +++ b/src/app/features/note/components/note-header/note-header.component.html @@ -8,6 +8,28 @@ + +
{{ pathParts.prefix }} diff --git a/src/app/features/note/components/note-header/note-header.component.ts b/src/app/features/note/components/note-header/note-header.component.ts index b48c94d..63f1430 100644 --- a/src/app/features/note/components/note-header/note-header.component.ts +++ b/src/app/features/note/components/note-header/note-header.component.ts @@ -1,8 +1,13 @@ -import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { debounceTime, Subject } from 'rxjs'; import { splitPathKeepFilename } from '../../../../shared/utils/path'; import { TagManagerComponent } from '../../../../shared/tags/tag-manager/tag-manager.component'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component'; +import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service'; +import { VaultService } from '../../../../../services/vault.service'; @Component({ selector: 'app-note-header', @@ -25,6 +30,15 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy { private ro?: ResizeObserver; private resize$ = new Subject(); + // Properties popover + popoverOpen = false; + private overlayRef?: OverlayRef; + private closeTimer?: any; + private overlay = inject(Overlay); + private vcr = inject(ViewContainerRef); + private frontmatterService = inject(FrontmatterPropertiesService); + private vaultService = inject(VaultService); + constructor(private host: ElementRef) {} ngAfterViewInit(): void { @@ -46,6 +60,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy { ngOnDestroy(): void { this.ro?.disconnect(); + this.closePopover(); } private applyProgressiveCollapse(): void { @@ -126,4 +141,63 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy { event.preventDefault(); this.copyRequested.emit(); } + + openPopover(origin: HTMLElement): void { + clearTimeout(this.closeTimer); + if (this.overlayRef && this.popoverOpen) return; + + const positionStrategy = this.overlay.position() + .flexibleConnectedTo(origin) + .withPositions([ + { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8 }, + { originX: 'center', originY: 'top', overlayX: 'center', overlayY: 'bottom', offsetY: -8 }, + ]) + .withPush(true); + + this.overlayRef = this.overlay.create({ + positionStrategy, + hasBackdrop: false, + scrollStrategy: this.overlay.scrollStrategies.reposition(), + }); + + const portal = new ComponentPortal(PropertiesPopoverComponent, this.vcr); + const compRef = this.overlayRef.attach(portal); + + // Get note and properties + const note = this.vaultService.getNoteById(this.noteId); + const props = this.frontmatterService.get(note); + compRef.instance.props = props; + + compRef.instance.requestClose.subscribe(() => this.scheduleClose()); + compRef.instance.cancelClose.subscribe(() => clearTimeout(this.closeTimer)); + + this.overlayRef.outsidePointerEvents().subscribe(() => this.closePopover()); + this.overlayRef.detachments().subscribe(() => { + this.popoverOpen = false; + }); + + this.popoverOpen = true; + } + + scheduleClose(): void { + clearTimeout(this.closeTimer); + this.closeTimer = setTimeout(() => this.closePopover(), 150); + } + + togglePopover(origin: HTMLElement): void { + if (this.popoverOpen) { + this.closePopover(); + } else { + this.openPopover(origin); + } + } + + closePopover(): void { + clearTimeout(this.closeTimer); + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = undefined; + } + this.popoverOpen = false; + } } diff --git a/src/app/features/note/components/properties-popover/properties-popover.component.html b/src/app/features/note/components/properties-popover/properties-popover.component.html new file mode 100644 index 0000000..fc1c820 --- /dev/null +++ b/src/app/features/note/components/properties-popover/properties-popover.component.html @@ -0,0 +1,71 @@ + diff --git a/src/app/features/note/components/properties-popover/properties-popover.component.ts b/src/app/features/note/components/properties-popover/properties-popover.component.ts new file mode 100644 index 0000000..f01bfab --- /dev/null +++ b/src/app/features/note/components/properties-popover/properties-popover.component.ts @@ -0,0 +1,100 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { StateChipComponent } from '../state-chip/state-chip.component'; +import { + NoteProperties, + NotePropertyEntry, + NotePropertyStates, + NotePropertySummary, +} from '../../shared/note-properties.model'; + +@Component({ + selector: 'app-properties-popover', + standalone: true, + imports: [CommonModule, StateChipComponent], + templateUrl: './properties-popover.component.html' +}) +export class PropertiesPopoverComponent { + @Input() props: NoteProperties | null = null; + @Output() requestClose = new EventEmitter(); + @Output() cancelClose = new EventEmitter(); + + private readonly summaryConfig: Array<{ + key: keyof NotePropertySummary; + label: string; + type: 'text' | 'date'; + }> = [ + { key: 'title', label: 'Titre', type: 'text' }, + { key: 'author', label: 'Auteur', type: 'text' }, + { key: 'creationDate', label: 'Créé le', type: 'date' }, + { key: 'modificationDate', label: 'Modifié le', type: 'date' }, + { key: 'category', label: 'Catégorie', type: 'text' }, + ]; + + private readonly stateOrder: Array<{ + key: keyof NotePropertyStates; + label: string; + }> = [ + { key: 'publish', label: 'Publié' }, + { key: 'favoris', label: 'Favori' }, + { key: 'archive', label: 'Archivé' }, + { key: 'draft', label: 'Brouillon' }, + { key: 'private', label: 'Privé' }, + ]; + + get summaryRows(): Array<{ label: string; value: string }> { + const summary = this.props?.summary; + if (!summary) return []; + const rows: Array<{ label: string; value: string }> = []; + for (const item of this.summaryConfig) { + const rawValue = summary[item.key]; + if (!rawValue) continue; + rows.push({ + label: item.label, + value: item.type === 'date' ? this.formatDate(rawValue) : rawValue, + }); + } + return rows; + } + + get hasSummary(): boolean { + return this.summaryRows.length > 0; + } + + get tags(): string[] { + return this.props?.tags ?? []; + } + + get aliases(): string[] { + return this.props?.aliases ?? []; + } + + get stateEntries(): Array<{ key: keyof NotePropertyStates; value: boolean | undefined }> { + const states = this.props?.states; + if (!states) return []; + return this.stateOrder + .map(({ key }) => ({ key, value: states[key] })) + .filter(entry => entry.value !== undefined); + } + + get additionalEntries(): NotePropertyEntry[] { + return this.props?.additional ?? []; + } + + formatDate(value?: string | null): string { + if (!value) return ''; + const date = new Date(value); + if (isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', timeStyle: 'short' + }).format(date); + } + + hasStates(): boolean { + return this.stateEntries.length > 0; + } + + hasAdditional(): boolean { + return this.additionalEntries.length > 0; + } +} diff --git a/src/app/features/note/components/state-chip/state-chip.component.html b/src/app/features/note/components/state-chip/state-chip.component.html new file mode 100644 index 0000000..cbea531 --- /dev/null +++ b/src/app/features/note/components/state-chip/state-chip.component.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + {{ label }} + diff --git a/src/app/features/note/components/state-chip/state-chip.component.ts b/src/app/features/note/components/state-chip/state-chip.component.ts new file mode 100644 index 0000000..810d410 --- /dev/null +++ b/src/app/features/note/components/state-chip/state-chip.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-state-chip', + standalone: true, + imports: [CommonModule], + templateUrl: './state-chip.component.html' +}) +export class StateChipComponent { + @Input() state!: 'publish' | 'favoris' | 'archive' | 'draft' | 'private'; + @Input() value: boolean | null | undefined; + + get label(): string { + switch (this.state) { + case 'publish': return 'Publié'; + case 'favoris': return 'Favori'; + case 'archive': return this.value ? 'Archivé' : 'Non archivé'; + case 'draft': return 'Brouillon'; + case 'private': return 'Privé'; + } + } + + get icon(): 'archive' | 'box' | 'globe' | 'heart' | 'file' | 'lock' | 'lock-open' { + if (this.state === 'archive') return this.value ? 'archive' : 'box'; + if (this.state === 'publish') return 'globe'; + if (this.state === 'favoris') return 'heart'; + if (this.state === 'draft') return 'file'; + return this.value ? 'lock' : 'lock-open'; + } +} diff --git a/src/app/features/note/shared/frontmatter-properties.service.ts b/src/app/features/note/shared/frontmatter-properties.service.ts new file mode 100644 index 0000000..98976cb --- /dev/null +++ b/src/app/features/note/shared/frontmatter-properties.service.ts @@ -0,0 +1,242 @@ +import { Injectable } from '@angular/core'; +import { Note } from '../../../../types'; +import { + NoteProperties, + NotePropertyEntry, + NotePropertyStates, + NotePropertySummary, +} from './note-properties.model'; + +interface CacheEntry { timestamp: string | number | undefined; props: NoteProperties } + +@Injectable({ providedIn: 'root' }) +export class FrontmatterPropertiesService { + private cache = new Map(); + + get(note: Note | null | undefined): NoteProperties | null { + if (!note) return null; + const cacheKey = note.id; + const timestamp = note.updatedAt ?? note.mtime; + const cached = this.cache.get(cacheKey); + if (cached && cached.timestamp === timestamp) return cached.props; + + const frontmatter = (note.frontmatter ?? {}) as Record; + + const consumedRawKeys = new Set(); + + const summary: NotePropertySummary = { + title: this.toStr(frontmatter, consumedRawKeys, ['title', 'titre'], note.title), + author: this.toStr(frontmatter, consumedRawKeys, ['auteur', 'author']), + creationDate: this.toStr(frontmatter, consumedRawKeys, ['creation_date', 'creation-date', 'created', 'datecreated'], note.createdAt), + modificationDate: this.toStr(frontmatter, consumedRawKeys, ['modification_date', 'modification-date', 'updated', 'last_modified', 'last-modified'], note.updatedAt), + category: this.toStr(frontmatter, consumedRawKeys, ['catégorie', 'categorie', 'category']), + }; + + const tags = this.toArray(frontmatter, consumedRawKeys, ['tags', 'tag']) ?? []; + const aliases = this.toArray(frontmatter, consumedRawKeys, ['aliases', 'alias']) ?? []; + + const states: NotePropertyStates = {}; + this.assignState(states, 'publish', frontmatter, consumedRawKeys, ['publish', 'publié']); + this.assignState(states, 'favoris', frontmatter, consumedRawKeys, ['favoris', 'favorite', 'favourite']); + this.assignState(states, 'archive', frontmatter, consumedRawKeys, ['archive', 'archived']); + this.assignState(states, 'draft', frontmatter, consumedRawKeys, ['draft', 'brouillon']); + this.assignState(states, 'private', frontmatter, consumedRawKeys, ['private', 'privé', 'prive']); + + const additional = this.buildAdditionalEntries(frontmatter, consumedRawKeys); + + const props: NoteProperties = { + summary, + tags, + aliases, + states: this.hasAnyState(states) ? states : null, + additional, + }; + + this.cache.set(cacheKey, { timestamp, props }); + return props; + } + + private toStr( + source: Record, + consumed: Set, + keys: string[], + fallback?: unknown, + ): string | undefined { + for (const key of keys) { + const raw = this.pick(source, consumed, key); + if (raw != null) { + const value = String(raw).trim(); + if (value) return value; + } + } + if (fallback != null) { + const value = String(fallback).trim(); + return value || undefined; + } + return undefined; + } + + private toArray( + source: Record, + consumed: Set, + keys: string[], + ): string[] | undefined { + for (const key of keys) { + const raw = this.pick(source, consumed, key); + if (raw == null) continue; + if (Array.isArray(raw)) { + const normalized = raw.map(v => String(v).trim()).filter(Boolean); + if (normalized.length) return normalized; + } + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + try { + const parsed = JSON.parse(trimmed.replace(/([a-zA-Z0-9_]+):/g, '"$1":')); + if (Array.isArray(parsed)) { + const normalized = parsed.map((v: unknown) => String(v).trim()).filter(Boolean); + if (normalized.length) return normalized; + continue; + } + } catch { + // ignore parse error + } + } + const normalized = trimmed.split(/[;,]/).map(v => v.trim()).filter(Boolean); + if (normalized.length) return normalized; + } + } + return undefined; + } + + private assignState( + target: NotePropertyStates, + targetKey: keyof NotePropertyStates, + source: Record, + consumed: Set, + keys: string[], + ): void { + for (const key of keys) { + const raw = this.pick(source, consumed, key); + if (raw == null) continue; + const value = this.toBool(raw); + if (value === undefined) continue; + target[targetKey] = value; + return; + } + } + + private buildAdditionalEntries( + source: Record, + consumed: Set, + ): NotePropertyEntry[] { + const entries: NotePropertyEntry[] = []; + for (const [rawKey, rawValue] of Object.entries(source)) { + if (rawValue === undefined || rawValue === null) continue; + if (consumed.has(rawKey)) continue; + + const formatted = this.formatAdditionalValue(rawValue); + if (!formatted) continue; + + entries.push({ + key: rawKey, + label: this.humanizeKey(rawKey), + displayValue: formatted.text, + multiline: formatted.multiline, + }); + } + + entries.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); + return entries; + } + + private hasAnyState(states: NotePropertyStates): boolean { + return Object.values(states).some(v => v !== undefined); + } + + private pick( + source: Record, + consumed: Set, + key: string, + ): unknown { + const normalizedKey = this.normalizeKey(key); + for (const candidate of Object.keys(source)) { + if (this.normalizeKey(candidate) === normalizedKey) { + consumed.add(candidate); + return source[candidate]; + } + } + return undefined; + } + + private toBool(value: unknown): boolean | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return undefined; + const truthy = ['true', 'yes', 'y', '1', 'oui', 'vrai']; + const falsy = ['false', 'no', 'n', '0', 'non', 'faux']; + if (truthy.includes(normalized)) return true; + if (falsy.includes(normalized)) return false; + return undefined; + } + + private formatAdditionalValue(value: unknown): { text: string; multiline: boolean } | null { + if (value === null || value === undefined) return null; + + if (typeof value === 'boolean') { + return { text: value ? 'Oui' : 'Non', multiline: false }; + } + + if (typeof value === 'number') { + return { text: String(value), multiline: false }; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + return { text: trimmed, multiline: trimmed.includes('\n') || trimmed.length > 48 }; + } + + if (Array.isArray(value)) { + if (!value.length) return null; + const items = value.map(item => + typeof item === 'object' && item !== null + ? JSON.stringify(item, null, 2) + : String(item) + ); + const text = items.join('\n'); + return { text, multiline: items.length > 1 || text.includes('\n') }; + } + + if (typeof value === 'object') { + try { + return { text: JSON.stringify(value, null, 2), multiline: true }; + } catch { + return { text: String(value), multiline: true }; + } + } + + return { text: String(value), multiline: false }; + } + + private humanizeKey(key: string): string { + const cleaned = key.replace(/[_-]+/g, ' ').trim(); + if (!cleaned) return key; + return cleaned + .toLowerCase() + .replace(/\b\w/g, match => match.toUpperCase()) + .replace('Id', 'ID'); + } + + private normalizeKey(input: string): string { + return input + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'); + } +} diff --git a/src/app/features/note/shared/note-properties.model.ts b/src/app/features/note/shared/note-properties.model.ts new file mode 100644 index 0000000..d570286 --- /dev/null +++ b/src/app/features/note/shared/note-properties.model.ts @@ -0,0 +1,30 @@ +export interface NoteProperties { + summary: NotePropertySummary; + tags: string[]; + aliases: string[]; + states: NotePropertyStates | null; + additional: NotePropertyEntry[]; +} + +export interface NotePropertySummary { + title?: string; + author?: string; + creationDate?: string; + modificationDate?: string; + category?: string; +} + +export interface NotePropertyStates { + publish?: boolean; + favoris?: boolean; + archive?: boolean; + draft?: boolean; + private?: boolean; +} + +export interface NotePropertyEntry { + key: string; + label: string; + displayValue: string; + multiline?: boolean; +} diff --git a/src/components/tags-view/note-viewer/note-viewer.component.html b/src/components/tags-view/note-viewer/note-viewer.component.html index 578d65f..c74596b 100644 --- a/src/components/tags-view/note-viewer/note-viewer.component.html +++ b/src/components/tags-view/note-viewer/note-viewer.component.html @@ -13,20 +13,6 @@
- @if (getFrontmatterKeys(note.frontmatter).length > 0) { -
-

Métadonnées

-
- @for (key of getFrontmatterKeys(note.frontmatter); track key) { -
- {{ key }} - {{ note.frontmatter[key] }} -
- } -
-
- } -
diff --git a/src/components/tags-view/note-viewer/note-viewer.component.ts b/src/components/tags-view/note-viewer/note-viewer.component.ts index 269f38e..dbafd6f 100644 --- a/src/components/tags-view/note-viewer/note-viewer.component.ts +++ b/src/components/tags-view/note-viewer/note-viewer.component.ts @@ -34,17 +34,6 @@ type MathJaxInstance = { }; }; -type MetadataEntryType = - | 'text' - | 'date' - | 'email' - | 'url' - | 'number' - | 'boolean' - | 'image' - | 'list' - | 'object'; - export interface WikiLinkActivation { target: string; heading?: string; @@ -53,17 +42,6 @@ export interface WikiLinkActivation { alias: string; } -interface MetadataEntry { - key: string; - label: string; - icon: string; - type: MetadataEntryType; - displayValue: string; - linkHref?: string; - imageUrl?: string; - booleanValue?: boolean; -} - @Component({ selector: 'app-note-viewer', standalone: true, @@ -176,45 +154,6 @@ interface MetadataEntry { } - @if (metadataEntries().length > 0) { - - } -
@@ -385,16 +324,12 @@ export class NoteViewerComponent implements OnDestroy { private mermaidLib: MermaidLib | null = null; private mathRenderScheduled = false; private mathJaxLoader: Promise | null = null; - private readonly dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }); - private readonly metadataKeysToExclude = new Set(['tags', 'tag', 'keywords']); private attachmentErrorCleanup: (() => void) | null = null; private attachmentHandlersScheduled = false; private wikiLinkHandlersScheduled = false; private previewOpenSub: Subscription | null = null; - readonly metadataExpanded = signal(false); readonly menuOpen = signal(false); - readonly maxMetadataPreviewItems = 3; readonly copyStatus = signal(''); // Edition state @@ -423,29 +358,6 @@ export class NoteViewerComponent implements OnDestroy { return []; }); - metadataEntries = computed(() => { - const frontmatter = this.note().frontmatter ?? {}; - const keys = this.getFrontmatterKeys(frontmatter); - const entries: MetadataEntry[] = []; - for (const key of keys) { - if (this.shouldSkipMetadataKey(key)) continue; - const rawValue = frontmatter[key]; - const entry = this.buildMetadataEntry(key, rawValue); - if (entry) entries.push(entry); - } - return entries; - }); - - metadataVisibleEntries = computed(() => { - const entries = this.metadataEntries(); - if (entries.length <= this.maxMetadataPreviewItems) return entries; - return this.metadataExpanded() ? entries : entries.slice(0, this.maxMetadataPreviewItems); - }); - - readonly metadataListId = computed(() => { - const rawId = this.note().id ?? this.note().title ?? 'metadata'; - return `metadata-list-${this.slugify(`${rawId}`)}`; - }); constructor() { effect(() => { @@ -582,7 +494,7 @@ export class NoteViewerComponent implements OnDestroy { const frontmatter = this.note().frontmatter ?? {}; const authorValue = frontmatter['author'] ?? frontmatter['auteur']; if (!authorValue) return null; - return this.coerceToString(authorValue).trim() || null; + return String(authorValue).trim() || null; } ngOnDestroy(): void { @@ -686,10 +598,6 @@ export class NoteViewerComponent implements OnDestroy { }); } - getFrontmatterKeys(frontmatter: { [key: string]: any }): string[] { - return Object.keys(frontmatter).filter(key => key !== 'tags' && key !== 'aliases' && key !== 'mtime'); - } - formatBacklinkId(id: string): string { return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); } @@ -943,203 +851,6 @@ export class NoteViewerComponent implements OnDestroy { return this.mathJaxLoader; } - private toggleMetadataPanel(): void { - if (typeof window !== 'undefined') { - const currentScroll = window.scrollY; - this.metadataExpanded.update(expanded => !expanded); - queueMicrotask(() => window.scrollTo({ top: currentScroll, behavior: 'auto' })); - return; - } - this.metadataExpanded.update(expanded => !expanded); - } - - private buildMetadataEntry(key: string, rawValue: unknown): MetadataEntry | null { - if (rawValue === undefined || rawValue === null) return null; - if (typeof rawValue === 'string' && rawValue.trim().length === 0) return null; - - const normalizedKey = key.toLowerCase(); - const label = this.formatMetadataLabel(key); - const baseString = this.coerceToString(rawValue); - const entry: MetadataEntry = { key, label, icon: '📝', type: 'text', displayValue: baseString }; - - if (Array.isArray(rawValue)) { - const parts = rawValue.map(v => this.coerceToString(v)).filter(Boolean); - if (!parts.length) return null; - entry.type = 'list'; - entry.icon = '🧾'; - entry.displayValue = parts.join(', '); - return entry; - } - - if (rawValue instanceof Date) { - if (Number.isNaN(rawValue.getTime())) return null; - entry.type = 'date'; - entry.icon = '📅'; - entry.displayValue = this.dateFormatter.format(rawValue); - return entry; - } - - const parsedDate = this.tryParseDate(rawValue); - if (parsedDate && (normalizedKey.includes('date') || normalizedKey.includes('time') || normalizedKey.includes('birth'))) { - entry.type = 'date'; - entry.icon = '📅'; - entry.displayValue = this.dateFormatter.format(parsedDate); - return entry; - } - - if (this.isBooleanLike(rawValue)) { - entry.type = 'boolean'; - entry.icon = '☑️'; - const value = this.toBoolean(rawValue); - entry.booleanValue = value; - entry.displayValue = value ? 'Oui' : 'Non'; - return entry; - } - - if (typeof rawValue === 'number' || (!Number.isNaN(Number(baseString)) && baseString.trim() !== '')) { - entry.type = 'number'; - entry.icon = '🔢'; - entry.displayValue = baseString; - return entry; - } - - if (this.looksLikeEmail(baseString) || normalizedKey.includes('email')) { - entry.type = 'email'; - entry.icon = '✉️'; - entry.displayValue = baseString; - entry.linkHref = `mailto:${baseString}`; - return entry; - } - - if (this.looksLikeImageUrl(baseString) || /(image|photo|avatar|picture|cover)/.test(normalizedKey)) { - const resolved = this.ensureUrlProtocol(baseString); - if (resolved) { - entry.type = 'image'; - entry.icon = '🖼️'; - entry.imageUrl = resolved; - entry.displayValue = baseString; - return entry; - } - } - - if (this.looksLikeUrl(baseString) || /(url|link|website)/.test(normalizedKey)) { - const resolved = this.ensureUrlProtocol(baseString); - if (resolved) { - entry.type = 'url'; - entry.icon = '🔗'; - entry.displayValue = baseString; - entry.linkHref = resolved; - return entry; - } - } - - if (typeof rawValue === 'object') { - try { - entry.type = 'object'; - entry.icon = '🧩'; - entry.displayValue = JSON.stringify(rawValue, null, 2); - return entry; - } catch { - return null; - } - } - - entry.icon = '📝'; - entry.type = 'text'; - entry.displayValue = baseString; - return entry; - } - - private formatMetadataLabel(key: string): string { - return key - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/^(\w)/, (_, first) => first.toUpperCase()) - .replace(/\b(\w)/g, m => m.toUpperCase()); - } - - private coerceToString(value: unknown): string { - if (value === null || value === undefined) return ''; - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' || typeof value === 'boolean') return `${value}`; - if (value instanceof Date) return this.dateFormatter.format(value); - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - - private looksLikeEmail(value: string): boolean { - if (!value) return false; - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); - } - - private looksLikeUrl(value: string): boolean { - if (!value) return false; - if (/^https?:\/\//i.test(value)) return true; - if (value.startsWith('www.')) return true; - try { - new URL(value); - return true; - } catch { - return false; - } - } - - private ensureUrlProtocol(value: string): string | null { - if (!value) return null; - try { - const url = value.startsWith('http') ? new URL(value) : new URL(`https://${value}`); - return url.toString(); - } catch { - return null; - } - } - - private looksLikeImageUrl(value: string): boolean { - if (!value) return false; - return /(\.(png|jpe?g|gif|svg|webp|avif|heic|heif)$)/i.test(value.split('?')[0]); - } - - private tryParseDate(value: unknown): Date | null { - if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = Date.parse(trimmed); - if (Number.isNaN(parsed)) return null; - return new Date(parsed); - } - - private isBooleanLike(value: unknown): boolean { - if (typeof value === 'boolean') return true; - if (typeof value === 'number') return value === 0 || value === 1; - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase(); - return ['true', 'false', 'oui', 'non', 'yes', 'no', '1', '0'].includes(normalized); - } - return false; - } - - private translate(key: 'metadata.showAll' | 'metadata.collapse'): string { - const translations: Record = { - 'metadata.showAll': 'Afficher tout', - 'metadata.collapse': 'Réduire', - }; - return translations[key] ?? key; - } - - private shouldSkipMetadataKey(key: string): boolean { - const normalized = key.trim().toLowerCase(); - return this.metadataKeysToExclude.has(normalized); - } - - private slugify(value: string): string { - return value.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-'); - } - private toBoolean(value: unknown): boolean { if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; diff --git a/vault/Nouveau-markdown.md b/vault/Nouveau-markdown.md index c357965..3712946 100644 --- a/vault/Nouveau-markdown.md +++ b/vault/Nouveau-markdown.md @@ -5,11 +5,7 @@ creation_date: 2025-10-19T21:42:53-04:00 modification_date: 2025-10-19T21:43:06-04:00 catégorie: markdown tags: - - tag1 - - tag2 - - tag3 - - tag4 - - markdown + - allo aliases: - nouveau status: en-cours @@ -21,9 +17,15 @@ archive: true draft: true private: true --- -# Nouveau-markdown +# Test 1 Markdown -#tag1 #tag2 #tag3 #tag4 +## Titres + +# Niveau 1 +#tag1 #tag2 #test #test2 + + +# Nouveau-markdown ## sous-titre - [ ] allo @@ -32,6 +34,8 @@ private: true ## sous-titre 2 +#tag1 #tag2 #tag3 #tag4 + ## sous-titre 3 ## sous-titre 4 diff --git a/vault/Nouveau-markdown.md.bak b/vault/Nouveau-markdown.md.bak index eeef43b..96986d0 100644 --- a/vault/Nouveau-markdown.md.bak +++ b/vault/Nouveau-markdown.md.bak @@ -5,11 +5,7 @@ creation_date: 2025-10-19T21:42:53-04:00 modification_date: 2025-10-19T21:43:06-04:00 catégorie: markdown tags: - - tag1 - - tag2 - - tag3 - - tag4 - - markdown + - allo aliases: - nouveau status: en-cours @@ -21,17 +17,25 @@ archive: true draft: true private: true --- +# Test 1 Markdown + +## Titres + +# Niveau 1 +#tag1 #tag2 #test #test2 + + # Nouveau-markdown -#tag1 #tag2 #tag3 #tag4 - ## sous-titre -- [] allo -- [] toto -- [] tata +- [ ] allo +- [ ] toto +- [ ] tata ## sous-titre 2 +#tag1 #tag2 #tag3 #tag4 + ## sous-titre 3 ## sous-titre 4 diff --git a/vault/folder1/test2.md b/vault/folder1/test2.md index 388f04c..e2318bd 100644 --- a/vault/folder1/test2.md +++ b/vault/folder1/test2.md @@ -4,12 +4,7 @@ auteur: Bruno Charest creation_date: 2025-10-02T16:10:42-04:00 modification_date: 2025-10-19T12:09:47-04:00 catégorie: "" -tags: - - accueil - - markdown - - bruno - - tag1 - - tag3 +tags: [] aliases: [] status: en-cours publish: false @@ -21,4 +16,6 @@ draft: false private: false tag: testTag --- -Ceci est la page 1 \ No newline at end of file +# Title +#tag1 #tag2 + diff --git a/vault/folder1/test2.md.bak b/vault/folder1/test2.md.bak index b5dc33e..4f84e14 100644 --- a/vault/folder1/test2.md.bak +++ b/vault/folder1/test2.md.bak @@ -4,6 +4,12 @@ auteur: Bruno Charest creation_date: 2025-10-02T16:10:42-04:00 modification_date: 2025-10-19T12:09:47-04:00 catégorie: "" +tags: + - accueil + - markdown + - bruno + - tag1 + - tag3 aliases: [] status: en-cours publish: false @@ -14,11 +20,7 @@ archive: false draft: false private: false tag: testTag -tags: - - accueil - - markdown - - bruno - - tag1 - - tag3 --- -Ceci est la page 1 \ No newline at end of file +# Title +#tag1 #tag2 + diff --git a/vault/test.md b/vault/test.md index f0c9ae0..a42276d 100644 --- a/vault/test.md +++ b/vault/test.md @@ -27,13 +27,12 @@ todo: false url: https://google.com image: https://images.unsplash.com/photo-1675789652575-0a5d2425b6c2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80 --- -#tag1 #tag2 #test #test2 - # Test 1 Markdown ## Titres # Niveau 1 +#tag1 #tag2 #test #test2 ## Niveau 2 diff --git a/vault/test.md.bak b/vault/test.md.bak index f0c9ae0..85fc2ed 100644 --- a/vault/test.md.bak +++ b/vault/test.md.bak @@ -27,13 +27,13 @@ todo: false url: https://google.com image: https://images.unsplash.com/photo-1675789652575-0a5d2425b6c2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80 --- -#tag1 #tag2 #test #test2 # Test 1 Markdown ## Titres # Niveau 1 +#tag1 #tag2 #test #test2 ## Niveau 2