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
This commit is contained in:
parent
b1da9b111d
commit
0ae9cae1eb
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<string>();
|
||||
@Output() openFolderRequested = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
|
||||
|
||||
readonly showMenu = signal(false);
|
||||
readonly searchQuery = signal('');
|
||||
readonly breadcrumb = signal<FolderEntry[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly processingPath = signal<string | null>(null);
|
||||
readonly currentFolderPath = signal('');
|
||||
readonly rootFolders = signal<FolderEntry[]>([]);
|
||||
readonly currentLevelFolders = signal<FolderEntry[]>([]);
|
||||
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
|
||||
|
||||
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<FlattenedFolder[]>(() => {
|
||||
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<HTMLElement>);
|
||||
|
||||
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<void> {
|
||||
event?.stopPropagation();
|
||||
await this.performMove(folder.path);
|
||||
}
|
||||
|
||||
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
|
||||
await this.performMove(result.path);
|
||||
}
|
||||
|
||||
moveToCurrentFolder(): Promise<void> {
|
||||
return this.performMove(this.activeFolderPath());
|
||||
}
|
||||
|
||||
onEscapeKey(): void {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
private async performMove(destinationPath: string): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
<div class="relative" (keydown.escape.stop)="onEscapeKey()">
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||
(click)="toggleMenu()"
|
||||
>
|
||||
<span class="truncate" [title]="currentPath">
|
||||
{{ currentFolderLabel() }}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">▾</span>
|
||||
</button>
|
||||
|
||||
@if (showMenu()) {
|
||||
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
|
||||
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
|
||||
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||
</div>
|
||||
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
<input
|
||||
#searchField
|
||||
type="text"
|
||||
class="bg-transparent text-sm flex-1 outline-none"
|
||||
placeholder="Search folders"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($any($event.target).value)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()">✕</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isSearching() && searchResults().length === 0) {
|
||||
<div class="text-xs text-muted px-1">No matching folders</div>
|
||||
}
|
||||
|
||||
@if (!isSearching()) {
|
||||
<div class="text-xs text-muted px-1">All my folders</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted/80">
|
||||
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
|
||||
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
|
||||
<span>›</span>
|
||||
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
@if (loading()) {
|
||||
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
|
||||
} @else {
|
||||
@for (folder of currentLevelFolders(); track folder.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
|
||||
(click)="onFolderSelected(folder, $event)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"></path>
|
||||
</svg>
|
||||
<span class="truncate">{{ folder.name }}</span>
|
||||
</span>
|
||||
@if (folder.children?.length) {
|
||||
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
|
||||
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isSearching()) {
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
@for (result of searchResults(); track result.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
|
||||
(click)="onSearchResultSelected(result)"
|
||||
>
|
||||
<span class="font-medium">{{ result.name }}</span>
|
||||
<span class="text-xs text-muted">{{ result.breadcrumb.join(' / ') }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
|
||||
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@ -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<string>();
|
||||
@Output() openFolderRequested = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('trigger') trigger?: ElementRef<HTMLElement>;
|
||||
|
||||
readonly showMenu = signal(false);
|
||||
readonly searchQuery = signal('');
|
||||
readonly breadcrumb = signal<FolderEntry[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly processingPath = signal<string | null>(null);
|
||||
readonly currentFolderPath = signal('');
|
||||
readonly rootFolders = signal<FolderEntry[]>([]);
|
||||
readonly currentLevelFolders = signal<FolderEntry[]>([]);
|
||||
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
|
||||
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<FlattenedFolder[]>(() => {
|
||||
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<HTMLElement>);
|
||||
|
||||
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<void> {
|
||||
event?.stopPropagation();
|
||||
await this.performMove(folder.path);
|
||||
}
|
||||
|
||||
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
|
||||
await this.performMove(result.path);
|
||||
}
|
||||
|
||||
moveToCurrentFolder(): Promise<void> {
|
||||
return this.performMove(this.activeFolderPath());
|
||||
}
|
||||
|
||||
onEscapeKey(): void {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
private async performMove(destinationPath: string): Promise<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -30,11 +30,14 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
|
||||
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis cursor-pointer" (click)="onPathClick()" [title]="fullPath">
|
||||
{{ pathParts.prefix }}
|
||||
</span>
|
||||
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
|
||||
<div class="path-wrap flex items-center gap-2 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
|
||||
<app-move-note-to-folder
|
||||
class="move-note-trigger shrink-0"
|
||||
[currentPath]="pathParts.prefix"
|
||||
[notePath]="fullPath"
|
||||
(noteMoved)="onNoteMoved($event)"
|
||||
(openFolderRequested)="onPathClick()"
|
||||
></app-move-note-to-folder>
|
||||
|
||||
<ng-container *ngIf="!isRenaming; else renameTpl">
|
||||
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">
|
||||
|
||||
@ -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<void>();
|
||||
@Output() tagsChange = new EventEmitter<string[]>();
|
||||
@Output() tagSelected = new EventEmitter<string>();
|
||||
@Output() noteMoved = new EventEmitter<string>();
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@ -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)"
|
||||
></app-note-header>
|
||||
@ -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<void> {
|
||||
const path = this.note()?.filePath ?? '';
|
||||
try {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user