From 0ae9cae1eba69c5d35b73a2f7cbf9ec3f450d0ee Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 25 Oct 2025 21:39:31 -0400 Subject: [PATCH] feat: add note moving functionality with UI controls - Added new API endpoint /api/vault/notes/move for moving markdown files between folders - Implemented setupMoveNoteEndpoint with path validation, error handling, and event broadcasting - Added move note UI component to note header with folder selection - Updated note viewer to handle note path changes after moving - Added moveNoteToFolder method to VaultService for client-side integration - Modified note header layout to include move trigger --- server/index-phase3-patch.mjs | 79 ++++ server/index.mjs | 6 +- .../move-note-to-folder.component.clean.ts | 352 +++++++++++++++++ .../move-note-to-folder.component.html | 104 +++++ .../move-note-to-folder.component.ts | 369 ++++++++++++++++++ .../note-header/note-header.component.html | 13 +- .../note-header/note-header.component.ts | 20 +- .../note-viewer/note-viewer.component.ts | 16 + src/services/vault.service.ts | 36 ++ vault/{folder-4 => tata}/test2.md | 0 10 files changed, 988 insertions(+), 7 deletions(-) create mode 100644 src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.clean.ts create mode 100644 src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html create mode 100644 src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts rename vault/{folder-4 => tata}/test2.md (100%) diff --git a/server/index-phase3-patch.mjs b/server/index-phase3-patch.mjs index fec82da..6cd02be 100644 --- a/server/index-phase3-patch.mjs +++ b/server/index-phase3-patch.mjs @@ -18,6 +18,7 @@ import path from 'path'; // ============================================================================ // ENDPOINT X: /api/files/rename - Rename a markdown file within the same folder // ============================================================================ + export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) { app.put('/api/files/rename', express.json(), (req, res) => { try { @@ -84,6 +85,84 @@ export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, meta }); } +// ============================================================================ +// ENDPOINT X+1: /api/vault/notes/move - Move a markdown file to another folder +// ============================================================================ +export function setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) { + app.post('/api/vault/notes/move', express.json(), (req, res) => { + try { + const { notePath, newFolderPath } = req.body || {}; + + if (!notePath || typeof notePath !== 'string') { + return res.status(400).json({ error: 'Missing or invalid notePath' }); + } + + const sanitizePath = (value = '') => String(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const sanitizedNotePath = sanitizePath(notePath.endsWith('.md') ? notePath : `${notePath}.md`); + if (!sanitizedNotePath || sanitizedNotePath.includes('..')) { + return res.status(400).json({ error: 'Invalid notePath' }); + } + + const sourceAbs = path.join(vaultDir, sanitizedNotePath); + if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) { + return res.status(404).json({ error: 'Source note not found' }); + } + + const sanitizedFolder = sanitizePath(typeof newFolderPath === 'string' ? newFolderPath : ''); + if (sanitizedFolder.includes('..')) { + return res.status(400).json({ error: 'Invalid destination folder' }); + } + if (sanitizedFolder.startsWith('__builtin__') || sanitizedFolder.startsWith('.trash')) { + return res.status(400).json({ error: 'Destination folder is not allowed' }); + } + + const destinationDir = sanitizedFolder ? path.join(vaultDir, sanitizedFolder) : vaultDir; + try { + fs.mkdirSync(destinationDir, { recursive: true }); + } catch (mkErr) { + console.error('[POST /api/vault/notes/move] Failed to ensure destination directory:', mkErr); + return res.status(500).json({ error: 'Failed to prepare destination folder' }); + } + + const fileName = path.basename(sourceAbs); + const destinationAbs = path.join(destinationDir, fileName); + if (sourceAbs === destinationAbs) { + return res.status(400).json({ error: 'Destination is same as source' }); + } + if (fs.existsSync(destinationAbs)) { + return res.status(409).json({ error: 'A note with this name already exists in the destination folder' }); + } + + try { + fs.renameSync(sourceAbs, destinationAbs); + } catch (renameErr) { + console.error('[POST /api/vault/notes/move] Move operation failed:', renameErr); + return res.status(500).json({ error: 'Failed to move note' }); + } + + const newRelPath = path.relative(vaultDir, destinationAbs).replace(/\\/g, '/'); + + try { metadataCache?.clear?.(); } catch {} + + try { + broadcastVaultEvent?.({ + event: 'file-move', + oldPath: sanitizedNotePath, + newPath: newRelPath, + timestamp: Date.now() + }); + } catch (evtErr) { + console.warn('[POST /api/vault/notes/move] Failed to broadcast event:', evtErr); + } + + return res.json({ success: true, oldPath: sanitizedNotePath, newPath: newRelPath }); + } catch (error) { + console.error('[POST /api/vault/notes/move] Unexpected error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + }); +} + // ============================================================================ // ENDPOINT 5: /api/folders/rename - Rename folder with validation // ============================================================================ diff --git a/server/index.mjs b/server/index.mjs index 717e8ed..bb25442 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -36,7 +36,8 @@ import { setupRenameFolderEndpoint, setupDeleteFolderEndpoint, setupCreateFolderEndpoint, - setupRenameFileEndpoint + setupRenameFileEndpoint, + setupMoveNoteEndpoint } from './index-phase3-patch.mjs'; const __filename = fileURLToPath(import.meta.url); @@ -1540,6 +1541,9 @@ setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); // Setup rename file endpoint (must be before catch-all) setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); +// Setup move note endpoint (must be before catch-all) +setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); + // Setup delete folder endpoint (must be before catch-all) setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.clean.ts b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.clean.ts new file mode 100644 index 0000000..866ddf7 --- /dev/null +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.clean.ts @@ -0,0 +1,352 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + DestroyRef, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, + computed, + effect, + inject, + signal +} from '@angular/core'; +import { splitPathKeepFilename } from '../../../../shared/utils/path'; +import { ToastService } from '../../../../shared/toast/toast.service'; +import { VaultService } from '../../../../../services/vault.service'; +import { VaultNode } from '../../../../../types'; + +interface FolderEntry { + name: string; + path: string; + children: FolderEntry[]; +} + +interface FlattenedFolder { + name: string; + path: string; + breadcrumb: string[]; +} + +@Component({ + selector: 'app-move-note-to-folder', + standalone: true, + imports: [CommonModule], + templateUrl: './move-note-to-folder.component.html' +}) +export class MoveNoteToFolderComponent { + @Input() + set currentPath(value: string | null | undefined) { + this.currentFolderPath.set(this.normalizeFolderPath(value)); + } + + @Input() notePath!: string; + + @Output() noteMoved = new EventEmitter(); + @Output() openFolderRequested = new EventEmitter(); + + @ViewChild('searchField') searchField?: ElementRef; + + readonly showMenu = signal(false); + readonly searchQuery = signal(''); + readonly breadcrumb = signal([]); + readonly loading = signal(true); + readonly processingPath = signal(null); + readonly currentFolderPath = signal(''); + readonly rootFolders = signal([]); + readonly currentLevelFolders = signal([]); + readonly flattenedFolders = signal([]); + + readonly isSearching = computed(() => this.searchQuery().trim().length > 0); + + readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders'); + + readonly activeFolderPath = computed(() => { + const crumbs = this.breadcrumb(); + return crumbs.length ? crumbs[crumbs.length - 1].path : ''; + }); + + readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath()); + + readonly searchResults = computed(() => { + const query = this.searchQuery().trim().toLowerCase(); + if (!query) { + return []; + } + + const results = this.flattenedFolders().filter(entry => { + const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase(); + return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query); + }); + + if ('all my folders'.includes(query)) { + results.unshift({ name: 'All my folders', path: '', breadcrumb: [] }); + } + + return results; + }); + + private readonly vault = inject(VaultService); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly host = inject(ElementRef); + + private listenersAttached = false; + + constructor() { + effect( + () => { + const nodes = this.vault.fastFileTree() as VaultNode[]; + this.buildFolderSources(nodes ?? []); + }, + { allowSignalWrites: true } + ); + + effect( + () => { + const path = this.currentFolderPath(); + const roots = this.rootFolders(); + this.setActiveLevel(path, roots); + }, + { allowSignalWrites: true } + ); + + this.destroyRef.onDestroy(() => this.detachGlobalListeners()); + } + + toggleMenu(): void { + if (this.showMenu()) { + this.closeMenu(); + return; + } + + this.searchQuery.set(''); + this.processingPath.set(null); + this.showMenu.set(true); + this.setActiveLevel(this.currentFolderPath(), this.rootFolders()); + this.attachGlobalListeners(); + + queueMicrotask(() => this.focusSearchField()); + } + + openFolderInSidebar(): void { + this.openFolderRequested.emit(); + this.closeMenu(); + } + + goToRoot(): void { + this.breadcrumb.set([]); + this.currentLevelFolders.set(this.rootFolders()); + } + + navigateInto(folder: FolderEntry, event: Event): void { + event.stopPropagation(); + this.breadcrumb.update(prev => [...prev, folder]); + this.currentLevelFolders.set(folder.children ?? []); + } + + navigateTo(level: number): void { + if (level <= 0) { + this.goToRoot(); + return; + } + + const trail = this.breadcrumb().slice(0, level); + this.breadcrumb.set(trail); + const last = trail[trail.length - 1]; + this.currentLevelFolders.set(last?.children ?? []); + } + + onSearchChange(value: string): void { + this.searchQuery.set(value); + } + + clearSearch(): void { + this.searchQuery.set(''); + queueMicrotask(() => this.focusSearchField()); + } + + async onFolderSelected(folder: FolderEntry, event?: Event): Promise { + event?.stopPropagation(); + await this.performMove(folder.path); + } + + async onSearchResultSelected(result: FlattenedFolder): Promise { + await this.performMove(result.path); + } + + moveToCurrentFolder(): Promise { + return this.performMove(this.activeFolderPath()); + } + + onEscapeKey(): void { + this.closeMenu(); + } + + private async performMove(destinationPath: string): Promise { + if (!this.notePath) { + return; + } + + const target = this.normalizeFolderPath(destinationPath); + const current = this.currentFolderPath(); + + if (target === current || this.processingPath()) { + this.closeMenu(); + return; + } + + this.processingPath.set(target || '__root__'); + + try { + const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target); + this.toast.success('Note moved successfully'); + + const { prefix } = splitPathKeepFilename(newPath); + this.currentFolderPath.set(this.normalizeFolderPath(prefix)); + this.noteMoved.emit(newPath); + this.searchQuery.set(''); + this.closeMenu(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to move note'; + this.toast.error(message); + } finally { + this.processingPath.set(null); + } + } + + private buildFolderSources(nodes: VaultNode[]): void { + const folders = this.mapFolders(nodes); + this.rootFolders.set(folders); + const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }]; + this.collectFolders(folders, [], flattened); + this.flattenedFolders.set(flattened); + this.loading.set(false); + } + + private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] { + if (!nodes?.length) { + return []; + } + + const folders: FolderEntry[] = []; + for (const node of nodes) { + if (node.type !== 'folder') continue; + + folders.push({ + name: node.name, + path: this.normalizeFolderPath(node.path), + children: this.mapFolders(node.children) + }); + } + + return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + } + + private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void { + for (const node of nodes) { + const breadcrumb = [...trail, node.name]; + acc.push({ name: node.name, path: node.path, breadcrumb }); + + if (node.children?.length) { + this.collectFolders(node.children, breadcrumb, acc); + } + } + } + + private setActiveLevel(path: string, roots: FolderEntry[]): void { + if (!roots.length) { + return; + } + + if (!path) { + this.breadcrumb.set([]); + this.currentLevelFolders.set(roots); + return; + } + + const trail = this.findTrail(roots, path); + if (!trail) { + this.breadcrumb.set([]); + this.currentLevelFolders.set(roots); + return; + } + + this.breadcrumb.set(trail); + const last = trail[trail.length - 1]; + this.currentLevelFolders.set(last?.children ?? []); + } + + private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null { + for (const node of nodes) { + const nextTrail = [...trail, node]; + if (node.path === target) { + return nextTrail; + } + if (node.children?.length) { + const found = this.findTrail(node.children, target, nextTrail); + if (found) { + return found; + } + } + } + return null; + } + + private closeMenu(): void { + this.showMenu.set(false); + this.detachGlobalListeners(); + this.searchQuery.set(''); + this.processingPath.set(null); + } + + private focusSearchField(): void { + const el = this.searchField?.nativeElement; + if (el) { + el.focus(); + el.select(); + } + } + + private attachGlobalListeners(): void { + if (typeof document === 'undefined' || this.listenersAttached) { + return; + } + document.addEventListener('pointerdown', this.handleOutsidePointer, true); + document.addEventListener('keydown', this.handleEscape, true); + this.listenersAttached = true; + } + + private detachGlobalListeners(): void { + if (typeof document === 'undefined' || !this.listenersAttached) { + return; + } + document.removeEventListener('pointerdown', this.handleOutsidePointer, true); + document.removeEventListener('keydown', this.handleEscape, true); + this.listenersAttached = false; + } + + private handleOutsidePointer = (event: PointerEvent) => { + if (!this.showMenu()) { + return; + } + if (!this.host.nativeElement.contains(event.target as Node)) { + this.closeMenu(); + } + }; + + private handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.showMenu()) { + this.closeMenu(); + } + }; + + private normalizeFolderPath(value: string | null | undefined): string { + return (value ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/g, '') + .replace(/\/+$/, '') + .trim(); + } +} diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html new file mode 100644 index 0000000..f02b34b --- /dev/null +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html @@ -0,0 +1,104 @@ +
+ + + @if (showMenu()) { +
+
+
Move note to folder
+ +
+ +
+
+ + + + + + @if (searchQuery()) { + + } +
+ + @if (isSearching() && searchResults().length === 0) { +
No matching folders
+ } + + @if (!isSearching()) { +
All my folders
+
+ + @for (crumb of breadcrumb(); track crumb.path; let i = $index) { + + + } +
+ +
+ @if (loading()) { +
Loading folders…
+ } @else { + @for (folder of currentLevelFolders(); track folder.path) { + + } + + } + } +
+ } + + @if (isSearching()) { +
+ @for (result of searchResults(); track result.path) { + + } +
+ } + +
+ + +
+
+
+ } +
diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts new file mode 100644 index 0000000..88c50fa --- /dev/null +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts @@ -0,0 +1,369 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + DestroyRef, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, + computed, + effect, + inject, + signal +} from '@angular/core'; +import { splitPathKeepFilename } from '../../../../shared/utils/path'; +import { ToastService } from '../../../../shared/toast/toast.service'; +import { VaultService } from '../../../../../services/vault.service'; +import { VaultNode } from '../../../../../types'; + +interface FolderEntry { + name: string; + path: string; + children: FolderEntry[]; +} + +interface FlattenedFolder { + name: string; + path: string; + breadcrumb: string[]; +} + +@Component({ + selector: 'app-move-note-to-folder', + standalone: true, + imports: [CommonModule], + templateUrl: './move-note-to-folder.component.html' +}) +export class MoveNoteToFolderComponent { + @Input() + set currentPath(value: string | null | undefined) { + this.currentFolderPath.set(this.normalizeFolderPath(value)); + } + + @Input() notePath!: string; + + @Output() noteMoved = new EventEmitter(); + @Output() openFolderRequested = new EventEmitter(); + + @ViewChild('searchField') searchField?: ElementRef; + @ViewChild('trigger') trigger?: ElementRef; + + readonly showMenu = signal(false); + readonly searchQuery = signal(''); + readonly breadcrumb = signal([]); + readonly loading = signal(true); + readonly processingPath = signal(null); + readonly currentFolderPath = signal(''); + readonly rootFolders = signal([]); + readonly currentLevelFolders = signal([]); + readonly flattenedFolders = signal([]); + readonly menuTop = signal(0); + readonly menuLeft = signal(0); + + readonly isSearching = computed(() => this.searchQuery().trim().length > 0); + + readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders'); + + readonly activeFolderPath = computed(() => { + const crumbs = this.breadcrumb(); + return crumbs.length ? crumbs[crumbs.length - 1].path : ''; + }); + + readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath()); + + readonly searchResults = computed(() => { + const query = this.searchQuery().trim().toLowerCase(); + if (!query) { + return []; + } + + const results = this.flattenedFolders().filter(entry => { + const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase(); + return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query); + }); + + if ('all my folders'.includes(query)) { + results.unshift({ name: 'All my folders', path: '', breadcrumb: [] }); + } + + return results; + }); + + private readonly vault = inject(VaultService); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + private readonly host = inject(ElementRef); + + private listenersAttached = false; + + constructor() { + effect( + () => { + const nodes = this.vault.fastFileTree() as VaultNode[]; + this.buildFolderSources(nodes ?? []); + }, + { allowSignalWrites: true } + ); + + effect( + () => { + const path = this.currentFolderPath(); + const roots = this.rootFolders(); + this.setActiveLevel(path, roots); + }, + { allowSignalWrites: true } + ); + + this.destroyRef.onDestroy(() => this.detachGlobalListeners()); + } + + toggleMenu(): void { + if (this.showMenu()) { + this.closeMenu(); + return; + } + + this.searchQuery.set(''); + this.processingPath.set(null); + this.showMenu.set(true); + this.setActiveLevel(this.currentFolderPath(), this.rootFolders()); + this.attachGlobalListeners(); + + queueMicrotask(() => { + this.computeMenuPosition(); + this.focusSearchField(); + }); + } + + openFolderInSidebar(): void { + this.openFolderRequested.emit(); + this.closeMenu(); + } + + goToRoot(): void { + this.breadcrumb.set([]); + this.currentLevelFolders.set(this.rootFolders()); + } + + navigateInto(folder: FolderEntry, event: Event): void { + event.stopPropagation(); + this.breadcrumb.update(prev => [...prev, folder]); + this.currentLevelFolders.set(folder.children ?? []); + } + + navigateTo(level: number): void { + if (level <= 0) { + this.goToRoot(); + return; + } + + const trail = this.breadcrumb().slice(0, level); + this.breadcrumb.set(trail); + const last = trail[trail.length - 1]; + this.currentLevelFolders.set(last?.children ?? []); + } + + onSearchChange(value: string): void { + this.searchQuery.set(value); + } + + clearSearch(): void { + this.searchQuery.set(''); + queueMicrotask(() => this.focusSearchField()); + } + + async onFolderSelected(folder: FolderEntry, event?: Event): Promise { + event?.stopPropagation(); + await this.performMove(folder.path); + } + + async onSearchResultSelected(result: FlattenedFolder): Promise { + await this.performMove(result.path); + } + + moveToCurrentFolder(): Promise { + return this.performMove(this.activeFolderPath()); + } + + onEscapeKey(): void { + this.closeMenu(); + } + + private async performMove(destinationPath: string): Promise { + if (!this.notePath) { + return; + } + + const target = this.normalizeFolderPath(destinationPath); + const current = this.currentFolderPath(); + + if (target === current || this.processingPath()) { + this.closeMenu(); + return; + } + + this.processingPath.set(target || '__root__'); + + try { + const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target); + this.toast.success('Note moved successfully'); + + const { prefix } = splitPathKeepFilename(newPath); + this.currentFolderPath.set(this.normalizeFolderPath(prefix)); + this.noteMoved.emit(newPath); + this.searchQuery.set(''); + this.closeMenu(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to move note'; + this.toast.error(message); + } finally { + this.processingPath.set(null); + } + } + + private buildFolderSources(nodes: VaultNode[]): void { + const folders = this.mapFolders(nodes); + this.rootFolders.set(folders); + const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }]; + this.collectFolders(folders, [], flattened); + this.flattenedFolders.set(flattened); + this.loading.set(false); + } + + private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] { + if (!nodes?.length) { + return []; + } + + const folders: FolderEntry[] = []; + for (const node of nodes) { + if (node.type !== 'folder') continue; + + folders.push({ + name: node.name, + path: this.normalizeFolderPath(node.path), + children: this.mapFolders(node.children) + }); + } + + return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + } + + private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void { + for (const node of nodes) { + const breadcrumb = [...trail, node.name]; + acc.push({ name: node.name, path: node.path, breadcrumb }); + + if (node.children?.length) { + this.collectFolders(node.children, breadcrumb, acc); + } + } + } + + private setActiveLevel(path: string, roots: FolderEntry[]): void { + if (!roots.length) { + return; + } + + if (!path) { + this.breadcrumb.set([]); + this.currentLevelFolders.set(roots); + return; + } + + const trail = this.findTrail(roots, path); + if (!trail) { + this.breadcrumb.set([]); + this.currentLevelFolders.set(roots); + return; + } + + this.breadcrumb.set(trail); + const last = trail[trail.length - 1]; + this.currentLevelFolders.set(last?.children ?? []); + } + + private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null { + for (const node of nodes) { + const nextTrail = [...trail, node]; + if (node.path === target) { + return nextTrail; + } + if (node.children?.length) { + const found = this.findTrail(node.children, target, nextTrail); + if (found) { + return found; + } + } + } + return null; + } + + private closeMenu(): void { + this.showMenu.set(false); + this.detachGlobalListeners(); + this.searchQuery.set(''); + this.processingPath.set(null); + } + + private focusSearchField(): void { + const el = this.searchField?.nativeElement; + if (el) { + el.focus(); + el.select(); + } + } + + private computeMenuPosition(): void { + const t = this.trigger?.nativeElement; + if (!t) return; + const rect = t.getBoundingClientRect(); + // Place menu just under the trigger, with small margin + this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8)); + // Try to align left edges; ensure not off-screen (simple clamp) + const left = Math.round(rect.left + window.scrollX); + this.menuLeft.set(Math.max(8, left)); + } + + private attachGlobalListeners(): void { + if (typeof document === 'undefined' || this.listenersAttached) { + return; + } + document.addEventListener('pointerdown', this.handleOutsidePointer, true); + document.addEventListener('keydown', this.handleEscape, true); + this.listenersAttached = true; + } + + private detachGlobalListeners(): void { + if (typeof document === 'undefined' || !this.listenersAttached) { + return; + } + document.removeEventListener('pointerdown', this.handleOutsidePointer, true); + document.removeEventListener('keydown', this.handleEscape, true); + this.listenersAttached = false; + } + + private handleOutsidePointer = (event: PointerEvent) => { + if (!this.showMenu()) { + return; + } + if (!this.host.nativeElement.contains(event.target as Node)) { + this.closeMenu(); + } + }; + + private handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.showMenu()) { + this.closeMenu(); + } + }; + + private normalizeFolderPath(value: string | null | undefined): string { + return (value ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/g, '') + .replace(/\/+$/, '') + .trim(); + } +} 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 84b991a..bf27b70 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 @@ -30,11 +30,14 @@ -
- - {{ 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 c52c15a..cd92f62 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 @@ -10,11 +10,12 @@ import { FrontmatterPropertiesService } from '../../shared/frontmatter-propertie import { VaultService } from '../../../../../services/vault.service'; import { ToastService } from '../../../../shared/toast/toast.service'; import { UrlStateService } from '../../../../services/url-state.service'; +import { MoveNoteToFolderComponent } from '../move-note-to-folder/move-note-to-folder.component'; @Component({ selector: 'app-note-header', standalone: true, - imports: [CommonModule, TagManagerComponent], + imports: [CommonModule, TagManagerComponent, MoveNoteToFolderComponent], templateUrl: './note-header.component.html', styleUrls: ['./note-header.component.scss'] }) @@ -27,6 +28,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges @Output() copyRequested = new EventEmitter(); @Output() tagsChange = new EventEmitter(); @Output() tagSelected = new EventEmitter(); + @Output() noteMoved = new EventEmitter(); pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' }; @@ -61,6 +63,22 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges } } + onNoteMoved(newPath: string): void { + if (!newPath) { + return; + } + + this.fullPath = newPath; + this.pathParts = splitPathKeepFilename(newPath); + this.noteMoved.emit(newPath); + this.urlState.openNote(newPath, { force: true }); + + queueMicrotask(() => { + this.applyProgressiveCollapse(); + this.fitPath(); + }); + } + ngAfterViewInit(): void { this.pathParts = splitPathKeepFilename(this.fullPath); 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 8558b4b..b659254 100644 --- a/src/components/tags-view/note-viewer/note-viewer.component.ts +++ b/src/components/tags-view/note-viewer/note-viewer.component.ts @@ -60,6 +60,7 @@ export interface WikiLinkActivation { [tags]="note.tags ?? []" (copyRequested)="copyPath()" (openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))" + (noteMoved)="onNoteMoved($event)" (tagsChange)="onTagsChange($event)" (tagSelected)="tagClicked.emit($event)" > @@ -479,6 +480,21 @@ export class NoteViewerComponent implements OnDestroy { // Pas besoin de sauvegarder ici, c'est déjà fait par TagsEditorComponent } + onNoteMoved(newPath: string): void { + const currentNote = this.note(); + if (!currentNote || !newPath) { + return; + } + + currentNote.filePath = newPath; + currentNote.originalPath = newPath.replace(/\\/g, '/').replace(/\.md$/i, ''); + + const newFolder = this.getDirectoryFromPath(newPath); + if (newFolder) { + this.directoryClicked.emit(newFolder); + } + } + async copyPath(): Promise { const path = this.note()?.filePath ?? ''; try { diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts index 1f0c896..5b8f8aa 100644 --- a/src/services/vault.service.ts +++ b/src/services/vault.service.ts @@ -230,6 +230,42 @@ export class VaultService implements OnDestroy { return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') }; } + /** Move a markdown file to a different folder within the vault. */ + async moveNoteToFolder(notePath: string, newFolderPath: string): Promise<{ oldPath: string; newPath: string }> { + const payload = { + notePath, + newFolderPath + }; + + const res = await fetch('/api/vault/notes/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + let reason = res.statusText; + try { + const errorBody = await res.json(); + reason = errorBody?.error || reason; + } catch { + // ignore parsing error, keep default status text + } + throw new Error(`Failed to move note: ${reason}`); + } + + const data = await res.json(); + + // Refresh local caches to reflect the new structure + this.refresh(); + this.loadFastFileTree(true); + + const oldPath = String(data.oldPath ?? notePath ?? ''); + const newPath = String(data.newPath ?? notePath ?? ''); + + return { oldPath, newPath }; + } + getNoteById(id: string): Note | undefined { return this.notesMap().get(id); } diff --git a/vault/folder-4/test2.md b/vault/tata/test2.md similarity index 100% rename from vault/folder-4/test2.md rename to vault/tata/test2.md