- 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
		
			
				
	
	
		
			947 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			947 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/**
 | 
						|
 * Phase 3 Patch - Endpoint modifications for caching and monitoring
 | 
						|
 * 
 | 
						|
 * This file contains the updated endpoints that should replace the old ones in index.mjs
 | 
						|
 * Apply these changes step by step:
 | 
						|
 * 
 | 
						|
 * 1. Replace /api/vault/metadata endpoint (lines ~500-551)
 | 
						|
 * 2. Replace /api/vault/metadata/paginated endpoint (lines ~553-620)
 | 
						|
 * 3. Add /__perf endpoint for monitoring (new)
 | 
						|
 * 4. Add startup hook for deferred Meilisearch indexing (new)
 | 
						|
 * 5. Add /api/folders/rename endpoint for folder renaming (new)
 | 
						|
 */
 | 
						|
 | 
						|
import express from 'express';
 | 
						|
import fs from 'fs';
 | 
						|
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 {
 | 
						|
      const { oldPath, newName } = req.body || {};
 | 
						|
 | 
						|
      if (!oldPath || typeof oldPath !== 'string') {
 | 
						|
        return res.status(400).json({ error: 'Missing or invalid oldPath' });
 | 
						|
      }
 | 
						|
      if (!newName || typeof newName !== 'string') {
 | 
						|
        return res.status(400).json({ error: 'Missing or invalid newName' });
 | 
						|
      }
 | 
						|
 | 
						|
      const invalidChars = /[\/\\:*?"<>|]/;
 | 
						|
      if (invalidChars.test(newName)) {
 | 
						|
        return res.status(400).json({ error: 'Invalid characters in newName' });
 | 
						|
      }
 | 
						|
 | 
						|
      const sanitizedOldRel = String(oldPath).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
 | 
						|
      const oldAbs = path.join(vaultDir, sanitizedOldRel);
 | 
						|
      if (!fs.existsSync(oldAbs) || !fs.statSync(oldAbs).isFile()) {
 | 
						|
        return res.status(404).json({ error: 'Source file not found' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Enforce .md extension and strip any other extension first
 | 
						|
      const baseNoExt = newName.replace(/\.[^/.]+$/, '');
 | 
						|
      const finalFileName = baseNoExt.endsWith('.md') ? baseNoExt : `${baseNoExt}.md`;
 | 
						|
 | 
						|
      const parentDir = path.dirname(oldAbs);
 | 
						|
      const newAbs = path.join(parentDir, finalFileName);
 | 
						|
      const newRel = path.relative(vaultDir, newAbs).replace(/\\/g, '/');
 | 
						|
 | 
						|
      // Prevent noop
 | 
						|
      const oldFileName = path.basename(oldAbs);
 | 
						|
      if (oldFileName === finalFileName) {
 | 
						|
        return res.status(400).json({ error: 'New name is same as current name' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Conflict check
 | 
						|
      if (fs.existsSync(newAbs)) {
 | 
						|
        return res.status(409).json({ error: 'A file with this name already exists' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Perform atomic-ish move
 | 
						|
      try {
 | 
						|
        fs.renameSync(oldAbs, newAbs);
 | 
						|
      } catch (e) {
 | 
						|
        console.error('[PUT /api/files/rename] rename failed:', e);
 | 
						|
        return res.status(500).json({ error: 'Failed to rename file' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Invalidate metadata cache
 | 
						|
      try { metadataCache?.clear?.(); } catch {}
 | 
						|
 | 
						|
      // Broadcast SSE event
 | 
						|
      try {
 | 
						|
        broadcastVaultEvent?.({ event: 'file-rename', oldPath: sanitizedOldRel, newPath: newRel, timestamp: Date.now() });
 | 
						|
      } catch {}
 | 
						|
 | 
						|
      return res.json({ success: true, oldPath: sanitizedOldRel, newPath: newRel, fileName: finalFileName });
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[PUT /api/files/rename] Unexpected error:', error);
 | 
						|
      return res.status(500).json({ error: 'Internal server error' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// 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
 | 
						|
// ============================================================================
 | 
						|
export function setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
 | 
						|
  app.put('/api/folders/rename', express.json(), (req, res) => {
 | 
						|
    try {
 | 
						|
      const { oldPath, newName } = req.body;
 | 
						|
 | 
						|
      // Validation
 | 
						|
      if (!oldPath || typeof oldPath !== 'string') {
 | 
						|
        return res.status(400).json({ error: 'Missing or invalid oldPath' });
 | 
						|
      }
 | 
						|
 | 
						|
 
 | 
						|
      if (!newName || typeof newName !== 'string') {
 | 
						|
        return res.status(400).json({ error: 'Missing or invalid newName' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Sanitize inputs
 | 
						|
      const sanitizedOldPath = oldPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
 | 
						|
      const sanitizedNewName = newName.trim();
 | 
						|
 | 
						|
      if (!sanitizedOldPath) {
 | 
						|
        return res.status(400).json({ error: 'Invalid oldPath' });
 | 
						|
      }
 | 
						|
      if (!sanitizedNewName) {
 | 
						|
        return res.status(400).json({ error: 'New name cannot be empty' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Prevent renaming to same name
 | 
						|
      const oldName = path.basename(sanitizedOldPath);
 | 
						|
      if (oldName === sanitizedNewName) {
 | 
						|
        return res.status(400).json({ error: 'New name is same as current name' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Construct paths
 | 
						|
      const oldFullPath = path.join(vaultDir, sanitizedOldPath);
 | 
						|
      const parentDir = path.dirname(oldFullPath);
 | 
						|
      const newFullPath = path.join(parentDir, sanitizedNewName);
 | 
						|
 | 
						|
      // Check if old folder exists
 | 
						|
      if (!fs.existsSync(oldFullPath)) {
 | 
						|
        return res.status(404).json({ error: 'Source folder not found' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Check if old path is actually a directory
 | 
						|
      const oldStats = fs.statSync(oldFullPath);
 | 
						|
      if (!oldStats.isDirectory()) {
 | 
						|
        return res.status(400).json({ error: 'Source path is not a directory' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Check if new folder already exists
 | 
						|
      if (fs.existsSync(newFullPath)) {
 | 
						|
        return res.status(409).json({ error: 'A folder with this name already exists' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Perform the rename
 | 
						|
      try {
 | 
						|
        fs.renameSync(oldFullPath, newFullPath);
 | 
						|
        console.log(`[PUT /api/folders/rename] Renamed "${sanitizedOldPath}" to "${sanitizedNewName}"`);
 | 
						|
      } catch (renameError) {
 | 
						|
        console.error('[PUT /api/folders/rename] Rename operation failed:', renameError);
 | 
						|
        return res.status(500).json({ error: 'Failed to rename folder' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Update Meilisearch index for all affected files
 | 
						|
      try {
 | 
						|
        // Find all files that were in the old folder path
 | 
						|
        const walkDir = (dir, fileList = []) => {
 | 
						|
          const files = fs.readdirSync(dir);
 | 
						|
          for (const file of files) {
 | 
						|
            const filePath = path.join(dir, file);
 | 
						|
            const stat = fs.statSync(filePath);
 | 
						|
            if (stat.isDirectory()) {
 | 
						|
              walkDir(filePath, fileList);
 | 
						|
            } else if (file.toLowerCase().endsWith('.md')) {
 | 
						|
              fileList.push(path.relative(vaultDir, filePath).replace(/\\/g, '/'));
 | 
						|
            }
 | 
						|
          }
 | 
						|
          return fileList;
 | 
						|
        };
 | 
						|
 | 
						|
        const affectedFiles = walkDir(newFullPath);
 | 
						|
        
 | 
						|
        // Re-index affected files with new paths
 | 
						|
        for (const filePath of affectedFiles) {
 | 
						|
          try {
 | 
						|
            // Re-index the file with new path
 | 
						|
            // Note: This would need to be implemented based on your indexing logic
 | 
						|
            console.log(`[PUT /api/folders/rename] Re-indexing: ${filePath}`);
 | 
						|
          } catch (indexError) {
 | 
						|
            console.warn(`[PUT /api/folders/rename] Failed to re-index ${filePath}:`, indexError);
 | 
						|
          }
 | 
						|
        }
 | 
						|
      } catch (indexError) {
 | 
						|
        console.warn('[PUT /api/folders/rename] Index update failed:', indexError);
 | 
						|
        // Don't fail the request if indexing fails
 | 
						|
      }
 | 
						|
 | 
						|
      // Invalidate metadata cache
 | 
						|
      if (metadataCache) metadataCache.clear();
 | 
						|
 | 
						|
      // Emit SSE event for immediate UI update
 | 
						|
      const newRelPath = path.relative(vaultDir, newFullPath).replace(/\\/g, '/');
 | 
						|
      if (broadcastVaultEvent) {
 | 
						|
        broadcastVaultEvent({
 | 
						|
          event: 'folder-rename',
 | 
						|
          oldPath: sanitizedOldPath,
 | 
						|
          newPath: newRelPath,
 | 
						|
          timestamp: Date.now()
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      res.json({
 | 
						|
        success: true,
 | 
						|
        oldPath: sanitizedOldPath,
 | 
						|
        newPath: newRelPath,
 | 
						|
        newName: sanitizedNewName,
 | 
						|
        message: `Folder renamed successfully`
 | 
						|
      });
 | 
						|
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[PUT /api/folders/rename] Unexpected error:', error);
 | 
						|
      res.status(500).json({ error: 'Internal server error' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT 6: /api/folders (DELETE) - Delete a folder recursively with validation
 | 
						|
// ============================================================================
 | 
						|
export function setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
 | 
						|
  const parsePathParam = (req) => {
 | 
						|
    const q = typeof req.query.path === 'string' ? req.query.path : '';
 | 
						|
    if (q) return q;
 | 
						|
    const ct = String(req.headers['content-type'] || '').split(';')[0];
 | 
						|
    if (ct === 'application/json' && req.body && typeof req.body.path === 'string') {
 | 
						|
      return req.body.path;
 | 
						|
    }
 | 
						|
    return '';
 | 
						|
  };
 | 
						|
 | 
						|
  app.delete('/api/folders', express.json(), (req, res) => {
 | 
						|
    try {
 | 
						|
      const rawPath = parsePathParam(req);
 | 
						|
      if (!rawPath) {
 | 
						|
        return res.status(400).json({ error: 'Missing or invalid path' });
 | 
						|
      }
 | 
						|
 | 
						|
      const sanitizedRel = rawPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
 | 
						|
      const abs = path.join(vaultDir, sanitizedRel);
 | 
						|
 | 
						|
      if (!fs.existsSync(abs)) {
 | 
						|
        return res.status(404).json({ error: 'Folder not found' });
 | 
						|
      }
 | 
						|
 | 
						|
      const st = fs.statSync(abs);
 | 
						|
      if (!st.isDirectory()) {
 | 
						|
        return res.status(400).json({ error: 'Path is not a directory' });
 | 
						|
      }
 | 
						|
 | 
						|
      try {
 | 
						|
        fs.rmSync(abs, { recursive: true, force: true });
 | 
						|
        console.log(`[DELETE /api/folders] Deleted folder "${sanitizedRel}"`);
 | 
						|
      } catch (delErr) {
 | 
						|
        console.error('[DELETE /api/folders] Delete failed:', delErr);
 | 
						|
        return res.status(500).json({ error: 'Failed to delete folder' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Invalidate metadata cache
 | 
						|
      if (metadataCache) metadataCache.clear();
 | 
						|
 | 
						|
      // Emit SSE event for immediate UI update
 | 
						|
      if (broadcastVaultEvent) {
 | 
						|
        broadcastVaultEvent({
 | 
						|
          event: 'folder-delete',
 | 
						|
          path: sanitizedRel,
 | 
						|
          timestamp: Date.now()
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      return res.json({ success: true, path: sanitizedRel });
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[DELETE /api/folders] Unexpected error:', error);
 | 
						|
      return res.status(500).json({ error: 'Internal server error' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT 7: /api/folders (POST) - Create a folder (supports { path } or { parentPath, newFolderName })
 | 
						|
// ============================================================================
 | 
						|
export function setupCreateFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
 | 
						|
  app.post('/api/folders', express.json(), async (req, res) => {
 | 
						|
    try {
 | 
						|
      const body = req.body || {};
 | 
						|
      let rel = '';
 | 
						|
      if (typeof body.path === 'string' && body.path.trim()) {
 | 
						|
        rel = body.path.trim();
 | 
						|
      } else if (typeof body.parentPath === 'string' && typeof body.newFolderName === 'string') {
 | 
						|
        const parent = body.parentPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
 | 
						|
        const name = body.newFolderName.trim();
 | 
						|
        if (!name) {
 | 
						|
          return res.status(400).json({ error: 'New folder name cannot be empty' });
 | 
						|
        }
 | 
						|
        rel = parent ? `${parent}/${name}` : name;
 | 
						|
      } else {
 | 
						|
        return res.status(400).json({ error: 'Missing path or (parentPath, newFolderName)' });
 | 
						|
      }
 | 
						|
 | 
						|
      const sanitizedRel = String(rel).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
 | 
						|
      if (!sanitizedRel) {
 | 
						|
        return res.status(400).json({ error: 'Invalid folder path' });
 | 
						|
      }
 | 
						|
 | 
						|
      const abs = path.join(vaultDir, sanitizedRel);
 | 
						|
      const vaultAbs = path.resolve(vaultDir);
 | 
						|
      const absResolved = path.resolve(abs);
 | 
						|
      if (!absResolved.startsWith(vaultAbs)) {
 | 
						|
        return res.status(400).json({ error: 'Path escapes vault root' });
 | 
						|
      }
 | 
						|
 | 
						|
      try {
 | 
						|
        await fs.promises.mkdir(absResolved, { recursive: true });
 | 
						|
      } catch (mkErr) {
 | 
						|
        console.error('[POST /api/folders] mkdir failed:', mkErr);
 | 
						|
        return res.status(500).json({ error: 'Failed to create folder' });
 | 
						|
      }
 | 
						|
 | 
						|
      if (metadataCache) metadataCache.clear();
 | 
						|
      if (broadcastVaultEvent) {
 | 
						|
        broadcastVaultEvent({ event: 'folder-create', path: sanitizedRel, timestamp: Date.now() });
 | 
						|
      }
 | 
						|
 | 
						|
      return res.json({ success: true, path: sanitizedRel });
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[POST /api/folders] Unexpected error:', error);
 | 
						|
      return res.status(500).json({ error: 'Internal server error' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT 1: /api/vault/metadata - with cache read-through and monitoring
 | 
						|
// ============================================================================
 | 
						|
export function setupMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { meiliClient, vaultIndexName, ensureIndexSettings, loadVaultMetadataOnly }) {
 | 
						|
  app.get('/api/vault/metadata', async (req, res) => {
 | 
						|
    const startTime = performanceMonitor.markRequestStart();
 | 
						|
    
 | 
						|
    try {
 | 
						|
      // Use cache.remember() for read-through caching
 | 
						|
      const { value: metadata, hit } = await metadataCache.remember(
 | 
						|
        `metadata:${vaultDir}`,
 | 
						|
        async () => {
 | 
						|
          // Try Meilisearch first with circuit breaker
 | 
						|
          try {
 | 
						|
            return await meilisearchCircuitBreaker.execute(
 | 
						|
              async () => {
 | 
						|
                const client = meiliClient();
 | 
						|
                const indexUid = vaultIndexName(vaultDir);
 | 
						|
                const index = await ensureIndexSettings(client, indexUid);
 | 
						|
                
 | 
						|
                const result = await index.search('', {
 | 
						|
                  limit: 10000,
 | 
						|
                  attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt']
 | 
						|
                });
 | 
						|
 | 
						|
                const items = Array.isArray(result.hits) ? result.hits.map(hit => ({
 | 
						|
                  id: hit.id,
 | 
						|
                  title: hit.title,
 | 
						|
                  filePath: hit.path,
 | 
						|
                  createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt,
 | 
						|
                  updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt,
 | 
						|
                })) : [];
 | 
						|
 | 
						|
                console.log(`[/api/vault/metadata] Loaded ${items.length} items from Meilisearch`);
 | 
						|
                return items;
 | 
						|
              },
 | 
						|
              {
 | 
						|
                onRetry: ({ attempt, delay, err }) => {
 | 
						|
                  console.warn(`[Meilisearch] Retry attempt ${attempt}, delay ${delay}ms:`, err.message);
 | 
						|
                  performanceMonitor.markRetry('meilisearch');
 | 
						|
                },
 | 
						|
                onCircuitOpen: ({ failureCount }) => {
 | 
						|
                  console.error(`[Meilisearch] Circuit breaker opened after ${failureCount} failures`);
 | 
						|
                }
 | 
						|
              }
 | 
						|
            );
 | 
						|
          } catch (meiliError) {
 | 
						|
            console.warn('[Meilisearch] Failed, falling back to filesystem:', meiliError.message);
 | 
						|
            
 | 
						|
            // Fallback to filesystem with retry
 | 
						|
            return await retryWithBackoff(
 | 
						|
              async () => {
 | 
						|
                const notes = await loadVaultMetadataOnly(vaultDir);
 | 
						|
                const metadata = notes.map(n => ({
 | 
						|
                  id: n.id,
 | 
						|
                  title: n.title,
 | 
						|
                  filePath: n.filePath,
 | 
						|
                  createdAt: n.createdAt,
 | 
						|
                  updatedAt: n.updatedAt
 | 
						|
                }));
 | 
						|
                
 | 
						|
                console.log(`[/api/vault/metadata] Loaded ${metadata.length} items from filesystem`);
 | 
						|
                return metadata;
 | 
						|
              },
 | 
						|
              {
 | 
						|
                retries: 2,
 | 
						|
                baseDelayMs: 100,
 | 
						|
                maxDelayMs: 500,
 | 
						|
                onRetry: ({ attempt, delay, err }) => {
 | 
						|
                  console.warn(`[Filesystem] Retry attempt ${attempt}, delay ${delay}ms:`, err.message);
 | 
						|
                  performanceMonitor.markRetry('filesystem');
 | 
						|
                }
 | 
						|
              }
 | 
						|
            );
 | 
						|
          }
 | 
						|
        }
 | 
						|
      );
 | 
						|
 | 
						|
      performanceMonitor.markCache(hit);
 | 
						|
      const duration = performanceMonitor.markRequestEnd(startTime, true);
 | 
						|
      
 | 
						|
      console.log(`[/api/vault/metadata] ${hit ? 'CACHE HIT' : 'CACHE MISS'} - ${duration}ms`);
 | 
						|
      
 | 
						|
      res.json({
 | 
						|
        items: metadata,
 | 
						|
        cached: hit,
 | 
						|
        duration
 | 
						|
      });
 | 
						|
    } catch (error) {
 | 
						|
      performanceMonitor.markRequestEnd(startTime, false);
 | 
						|
      console.error('[/api/vault/metadata] Error:', error);
 | 
						|
      res.status(500).json({ error: 'Unable to load vault metadata.' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT 2: /api/vault/metadata/paginated - with cache and monitoring
 | 
						|
// ============================================================================
 | 
						|
export function setupPaginatedMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { meiliClient, vaultIndexName, ensureIndexSettings, loadVaultMetadataOnly }) {
 | 
						|
  app.get('/api/vault/metadata/paginated', async (req, res) => {
 | 
						|
    const startTime = performanceMonitor.markRequestStart();
 | 
						|
    
 | 
						|
    try {
 | 
						|
      const limit = Math.min(parseInt(req.query.limit) || 100, 500);
 | 
						|
      const cursor = parseInt(req.query.cursor) || 0;
 | 
						|
      const search = req.query.search || '';
 | 
						|
      
 | 
						|
      const cacheKey = `paginated:${vaultDir}:${search}`;
 | 
						|
 | 
						|
      // For paginated requests, we cache the full result set and paginate client-side
 | 
						|
      const { value: allMetadata, hit } = await metadataCache.remember(
 | 
						|
        cacheKey,
 | 
						|
        async () => {
 | 
						|
          try {
 | 
						|
            return await meilisearchCircuitBreaker.execute(
 | 
						|
              async () => {
 | 
						|
                const client = meiliClient();
 | 
						|
                const indexUid = vaultIndexName(vaultDir);
 | 
						|
                const index = await ensureIndexSettings(client, indexUid);
 | 
						|
 | 
						|
                const result = await index.search(search, {
 | 
						|
                  limit: 10000,
 | 
						|
                  attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'],
 | 
						|
                  sort: ['updatedAt:desc']
 | 
						|
                });
 | 
						|
 | 
						|
                return Array.isArray(result.hits) ? result.hits.map(hit => ({
 | 
						|
                  id: hit.id,
 | 
						|
                  title: hit.title,
 | 
						|
                  filePath: hit.path,
 | 
						|
                  createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt,
 | 
						|
                  updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt,
 | 
						|
                })) : [];
 | 
						|
              },
 | 
						|
              {
 | 
						|
                onRetry: ({ attempt, delay, err }) => {
 | 
						|
                  console.warn(`[Meilisearch] Paginated retry ${attempt}, delay ${delay}ms:`, err.message);
 | 
						|
                  performanceMonitor.markRetry('meilisearch');
 | 
						|
                }
 | 
						|
              }
 | 
						|
            );
 | 
						|
          } catch (meiliError) {
 | 
						|
            console.warn('[Meilisearch] Paginated failed, falling back to filesystem:', meiliError.message);
 | 
						|
            
 | 
						|
            return await retryWithBackoff(
 | 
						|
              async () => {
 | 
						|
                const allMetadata = await loadVaultMetadataOnly(vaultDir);
 | 
						|
                
 | 
						|
                let filtered = allMetadata;
 | 
						|
                if (search) {
 | 
						|
                  const searchLower = search.toLowerCase();
 | 
						|
                  filtered = allMetadata.filter(item =>
 | 
						|
                    (item.title || '').toLowerCase().includes(searchLower) ||
 | 
						|
                    (item.filePath || '').toLowerCase().includes(searchLower)
 | 
						|
                  );
 | 
						|
                }
 | 
						|
 | 
						|
                filtered.sort((a, b) => {
 | 
						|
                  const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
 | 
						|
                  const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
 | 
						|
                  return dateB - dateA;
 | 
						|
                });
 | 
						|
 | 
						|
                return filtered.map(n => ({
 | 
						|
                  id: n.id,
 | 
						|
                  title: n.title,
 | 
						|
                  filePath: n.filePath,
 | 
						|
                  createdAt: n.createdAt,
 | 
						|
                  updatedAt: n.updatedAt
 | 
						|
                }));
 | 
						|
              },
 | 
						|
              {
 | 
						|
                retries: 2,
 | 
						|
                baseDelayMs: 100,
 | 
						|
                maxDelayMs: 500,
 | 
						|
                onRetry: ({ attempt, delay, err }) => {
 | 
						|
                  console.warn(`[Filesystem] Paginated retry ${attempt}, delay ${delay}ms:`, err.message);
 | 
						|
                  performanceMonitor.markRetry('filesystem');
 | 
						|
                }
 | 
						|
              }
 | 
						|
            );
 | 
						|
          }
 | 
						|
        }
 | 
						|
      );
 | 
						|
 | 
						|
      // Paginate the cached result
 | 
						|
      const paginatedItems = allMetadata.slice(cursor, cursor + limit);
 | 
						|
      const hasMore = cursor + limit < allMetadata.length;
 | 
						|
      const nextCursor = hasMore ? cursor + limit : null;
 | 
						|
 | 
						|
      performanceMonitor.markCache(hit);
 | 
						|
      const duration = performanceMonitor.markRequestEnd(startTime, true);
 | 
						|
      
 | 
						|
      console.log(`[/api/vault/metadata/paginated] ${hit ? 'CACHE HIT' : 'CACHE MISS'} - cursor=${cursor}, limit=${limit}, duration=${duration}ms`);
 | 
						|
 | 
						|
      res.json({
 | 
						|
        items: paginatedItems,
 | 
						|
        nextCursor,
 | 
						|
        hasMore,
 | 
						|
        total: allMetadata.length,
 | 
						|
        cached: hit,
 | 
						|
        duration
 | 
						|
      });
 | 
						|
    } catch (error) {
 | 
						|
      performanceMonitor.markRequestEnd(startTime, false);
 | 
						|
      console.error('[/api/vault/metadata/paginated] Error:', error);
 | 
						|
      res.status(500).json({ error: 'Pagination failed' });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT 3: /__perf - Performance monitoring dashboard
 | 
						|
// ============================================================================
 | 
						|
export function setupPerformanceEndpoint(app, performanceMonitor, metadataCache, meilisearchCircuitBreaker) {
 | 
						|
  app.get('/__perf', (req, res) => {
 | 
						|
    res.json({
 | 
						|
      performance: performanceMonitor.snapshot(),
 | 
						|
      cache: metadataCache.getStats(),
 | 
						|
      circuitBreaker: meilisearchCircuitBreaker.getState(),
 | 
						|
      timestamp: new Date().toISOString()
 | 
						|
    });
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// STARTUP HOOK: Deferred Meilisearch indexing (non-blocking)
 | 
						|
// ============================================================================
 | 
						|
export async function setupDeferredIndexing(vaultDir, fullReindex) {
 | 
						|
  let indexingInProgress = false;
 | 
						|
  let indexingCompleted = false;
 | 
						|
  let lastIndexingAttempt = 0;
 | 
						|
  const INDEXING_COOLDOWN = 5 * 60 * 1000; // 5 minutes
 | 
						|
 | 
						|
  async function scheduleIndexing() {
 | 
						|
    const now = Date.now();
 | 
						|
    
 | 
						|
    if (indexingInProgress || (now - lastIndexingAttempt) < INDEXING_COOLDOWN) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    indexingInProgress = true;
 | 
						|
    lastIndexingAttempt = now;
 | 
						|
 | 
						|
    console.log('[Meilisearch] Scheduling background indexing...');
 | 
						|
 | 
						|
    // Use setImmediate to not block startup
 | 
						|
    setImmediate(async () => {
 | 
						|
      try {
 | 
						|
        console.time('[Meilisearch] Background indexing');
 | 
						|
        
 | 
						|
        await fullReindex(vaultDir);
 | 
						|
        
 | 
						|
        console.timeEnd('[Meilisearch] Background indexing');
 | 
						|
        console.log('[Meilisearch] Background indexing completed successfully');
 | 
						|
        
 | 
						|
        indexingCompleted = true;
 | 
						|
      } catch (error) {
 | 
						|
        console.error('[Meilisearch] Background indexing failed:', error.message);
 | 
						|
        indexingCompleted = false;
 | 
						|
        
 | 
						|
        // Schedule retry in 5 minutes
 | 
						|
        setTimeout(() => {
 | 
						|
          console.log('[Meilisearch] Retrying indexing in 5 minutes...');
 | 
						|
          indexingInProgress = false;
 | 
						|
          scheduleIndexing();
 | 
						|
        }, INDEXING_COOLDOWN);
 | 
						|
      } finally {
 | 
						|
        indexingInProgress = false;
 | 
						|
      }
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  return {
 | 
						|
    scheduleIndexing,
 | 
						|
    getState: () => ({ indexingInProgress, indexingCompleted, lastIndexingAttempt })
 | 
						|
  };
 | 
						|
}
 | 
						|
 | 
						|
import { join, dirname, relative, basename } from 'path';
 | 
						|
import { existsSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT: POST /api/vault/notes - Create new note
 | 
						|
// ============================================================================
 | 
						|
export function setupCreateNoteEndpoint(app, vaultDir) {
 | 
						|
  console.log('[Setup] Setting up /api/vault/notes endpoint');
 | 
						|
  app.post('/api/vault/notes', async (req, res) => {
 | 
						|
    try {
 | 
						|
      const { fileName, folderPath, frontmatter, content = '' } = req.body;
 | 
						|
 | 
						|
      console.log('[/api/vault/notes] Request received:', { fileName, folderPath });
 | 
						|
 | 
						|
      if (!fileName) {
 | 
						|
        return res.status(400).json({ error: 'fileName is required' });
 | 
						|
      }
 | 
						|
 | 
						|
      if (!frontmatter || typeof frontmatter !== 'object') {
 | 
						|
        return res.status(400).json({ error: 'frontmatter is required and must be an object' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Ensure fileName ends with .md
 | 
						|
      const finalFileName = fileName.endsWith('.md') ? fileName : `${fileName}.md`;
 | 
						|
 | 
						|
      // Build full path - handle folderPath properly
 | 
						|
      let fullFolderPath = '';
 | 
						|
      if (folderPath && folderPath !== '/' && folderPath.trim() !== '') {
 | 
						|
        fullFolderPath = folderPath.replace(/^\/+/, '').replace(/\/+$/, ''); // Remove leading/trailing slashes
 | 
						|
      }
 | 
						|
      
 | 
						|
      const fullPath = fullFolderPath 
 | 
						|
        ? join(vaultDir, fullFolderPath, finalFileName)
 | 
						|
        : join(vaultDir, finalFileName);
 | 
						|
 | 
						|
      console.log('[/api/vault/notes] Full path:', fullPath);
 | 
						|
 | 
						|
      // Check if file already exists
 | 
						|
      if (existsSync(fullPath)) {
 | 
						|
        return res.status(409).json({ error: 'File already exists' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Format frontmatter to YAML
 | 
						|
      const frontmatterYaml = Object.keys(frontmatter).length > 0
 | 
						|
        ? `---\n${Object.entries(frontmatter)
 | 
						|
            .map(([key, value]) => {
 | 
						|
              if (typeof value === 'string') {
 | 
						|
                return `${key}: "${value}"`;
 | 
						|
              } else if (typeof value === 'boolean') {
 | 
						|
                return `${key}: ${value}`;
 | 
						|
              } else if (Array.isArray(value)) {
 | 
						|
                return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`;
 | 
						|
              }
 | 
						|
              return `${key}: ${value}`;
 | 
						|
            })
 | 
						|
            .join('\n')}\n---\n\n`
 | 
						|
        : '';
 | 
						|
 | 
						|
      // Create the full content
 | 
						|
      const fullContent = frontmatterYaml + content;
 | 
						|
 | 
						|
      // Ensure directory exists
 | 
						|
      const dir = dirname(fullPath);
 | 
						|
      console.log('[/api/vault/notes] Creating directory:', dir);
 | 
						|
      
 | 
						|
      if (!existsSync(dir)) {
 | 
						|
        mkdirSync(dir, { recursive: true });
 | 
						|
      }
 | 
						|
 | 
						|
      // Write the file
 | 
						|
      console.log('[/api/vault/notes] Writing file:', fullPath);
 | 
						|
      writeFileSync(fullPath, fullContent, 'utf8');
 | 
						|
 | 
						|
      // Generate ID (same logic as in vault loader)
 | 
						|
      const relativePath = relative(vaultDir, fullPath).replace(/\\/g, '/');
 | 
						|
      const id = relativePath.replace(/\.md$/, '');
 | 
						|
 | 
						|
      console.log(`[/api/vault/notes] Created note: ${relativePath}`);
 | 
						|
 | 
						|
      res.json({
 | 
						|
        id,
 | 
						|
        fileName: finalFileName,
 | 
						|
        filePath: relativePath,
 | 
						|
        success: true
 | 
						|
      });
 | 
						|
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[/api/vault/notes] Error creating note:', error.message, error.stack);
 | 
						|
      res.status(500).json({ error: 'Failed to create note', details: error.message });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// Simple YAML parser for frontmatter
 | 
						|
function parseYaml(yamlString) {
 | 
						|
  try {
 | 
						|
    const lines = yamlString.split('\n');
 | 
						|
    const result = {};
 | 
						|
    
 | 
						|
    for (const line of lines) {
 | 
						|
      const match = line.match(/^(\w+):\s*(.+)$/);
 | 
						|
      if (match) {
 | 
						|
        const [, key, value] = match;
 | 
						|
        // Handle different value types
 | 
						|
        if (value === 'true') result[key] = true;
 | 
						|
        else if (value === 'false') result[key] = false;
 | 
						|
        else if (value.startsWith('"') && value.endsWith('"')) result[key] = value.slice(1, -1);
 | 
						|
        else if (value.startsWith('[') && value.endsWith(']')) {
 | 
						|
          // Parse array
 | 
						|
          result[key] = value.slice(1, -1).split(',').map(v => v.trim().replace(/"/g, ''));
 | 
						|
        }
 | 
						|
        else result[key] = value;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    
 | 
						|
    return result;
 | 
						|
  } catch (e) {
 | 
						|
    return {};
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT: PATCH /api/vault/notes/:id - Update note frontmatter
 | 
						|
// ============================================================================
 | 
						|
export function setupUpdateNoteEndpoint(app, vaultDir) {
 | 
						|
  console.log('[Setup] Setting up regex PATCH /api/vault/notes/* endpoint');
 | 
						|
  // Use regex route to capture IDs with slashes (folder paths)
 | 
						|
  app.patch(/^\/api\/vault\/notes\/(.+)$/, async (req, res) => {
 | 
						|
    try {
 | 
						|
      const id = req.params[0];
 | 
						|
      const { frontmatter } = req.body;
 | 
						|
 | 
						|
      console.log('[/api/vault/notes/*] PATCH request received:', { id, frontmatter });
 | 
						|
 | 
						|
      if (!frontmatter || typeof frontmatter !== 'object') {
 | 
						|
        return res.status(400).json({ error: 'frontmatter is required and must be an object' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Build file path from ID
 | 
						|
      const filePath = join(vaultDir, `${id}.md`);
 | 
						|
      
 | 
						|
      if (!existsSync(filePath)) {
 | 
						|
        return res.status(404).json({ error: 'Note not found' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Read existing file
 | 
						|
      const existingContent = readFileSync(filePath, 'utf8');
 | 
						|
      
 | 
						|
      // Parse existing content
 | 
						|
      const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
 | 
						|
      const match = existingContent.match(frontmatterRegex);
 | 
						|
      
 | 
						|
      let existingFrontmatter = {};
 | 
						|
      let content = existingContent;
 | 
						|
      
 | 
						|
      if (match) {
 | 
						|
        // Parse existing YAML frontmatter
 | 
						|
        try {
 | 
						|
          existingFrontmatter = parseYaml(match[1]) || {};
 | 
						|
          content = match[2];
 | 
						|
        } catch (e) {
 | 
						|
          console.warn('[/api/vault/notes/:id] Failed to parse existing frontmatter:', e);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // Merge frontmatter updates
 | 
						|
      const updatedFrontmatter = { ...existingFrontmatter, ...frontmatter };
 | 
						|
      
 | 
						|
      // Remove undefined/null values
 | 
						|
      Object.keys(updatedFrontmatter).forEach(key => {
 | 
						|
        if (updatedFrontmatter[key] === undefined || updatedFrontmatter[key] === null) {
 | 
						|
          delete updatedFrontmatter[key];
 | 
						|
        }
 | 
						|
      });
 | 
						|
 | 
						|
      // Format new frontmatter to YAML
 | 
						|
      const frontmatterYaml = Object.keys(updatedFrontmatter).length > 0
 | 
						|
        ? `---\n${Object.entries(updatedFrontmatter)
 | 
						|
            .map(([key, value]) => {
 | 
						|
              if (typeof value === 'string') {
 | 
						|
                return `${key}: "${value}"`;
 | 
						|
              } else if (typeof value === 'boolean') {
 | 
						|
                return `${key}: ${value}`;
 | 
						|
              } else if (Array.isArray(value)) {
 | 
						|
                return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`;
 | 
						|
              } else {
 | 
						|
                return `${key}: ${value}`;
 | 
						|
              }
 | 
						|
            })
 | 
						|
            .join('\n')}\n---\n`
 | 
						|
        : '';
 | 
						|
 | 
						|
      // Write updated content
 | 
						|
      const fullContent = frontmatterYaml + content;
 | 
						|
      writeFileSync(filePath, fullContent, 'utf8');
 | 
						|
 | 
						|
      console.log(`[/api/vault/notes/*] Updated note: ${id}`);
 | 
						|
 | 
						|
      res.json({
 | 
						|
        id,
 | 
						|
        success: true,
 | 
						|
        frontmatter: updatedFrontmatter
 | 
						|
      });
 | 
						|
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[/api/vault/notes/*] Error updating note:', error.message, error.stack);
 | 
						|
      res.status(500).json({ error: 'Failed to update note', details: error.message });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 | 
						|
 | 
						|
// ============================================================================
 | 
						|
// ENDPOINT: DELETE /api/vault/notes/:id - Delete note (move to trash)
 | 
						|
// ============================================================================
 | 
						|
export function setupDeleteNoteEndpoint(app, vaultDir) {
 | 
						|
  console.log('[Setup] Setting up regex DELETE /api/vault/notes/* endpoint');
 | 
						|
  // Use regex route to capture IDs with slashes (folder paths)
 | 
						|
  app.delete(/^\/api\/vault\/notes\/(.+)$/, async (req, res) => {
 | 
						|
    try {
 | 
						|
      const id = req.params[0];
 | 
						|
 | 
						|
      console.log('[/api/vault/notes/*] DELETE request received:', { id });
 | 
						|
 | 
						|
      // Build file path from ID
 | 
						|
      const filePath = join(vaultDir, `${id}.md`);
 | 
						|
      
 | 
						|
      if (!existsSync(filePath)) {
 | 
						|
        return res.status(404).json({ error: 'Note not found' });
 | 
						|
      }
 | 
						|
 | 
						|
      // Create trash directory if it doesn't exist
 | 
						|
      const trashDir = join(vaultDir, '.trash');
 | 
						|
      if (!existsSync(trashDir)) {
 | 
						|
        mkdirSync(trashDir, { recursive: true });
 | 
						|
      }
 | 
						|
 | 
						|
      // Generate unique filename in trash
 | 
						|
      const originalName = basename(id);
 | 
						|
      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
 | 
						|
      const trashFileName = `${originalName}_${timestamp}.md`;
 | 
						|
      const trashPath = join(trashDir, trashFileName);
 | 
						|
 | 
						|
      // Move file to trash
 | 
						|
      renameSync(filePath, trashPath);
 | 
						|
 | 
						|
      console.log(`[/api/vault/notes/*] Moved note to trash: ${id} -> ${trashFileName}`);
 | 
						|
 | 
						|
      res.json({
 | 
						|
        id,
 | 
						|
        success: true,
 | 
						|
        trashPath: relative(vaultDir, trashPath).replace(/\\/g, '/')
 | 
						|
      });
 | 
						|
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[/api/vault/notes/*] Error deleting note:', error.message, error.stack);
 | 
						|
      res.status(500).json({ error: 'Failed to delete note', details: error.message });
 | 
						|
    }
 | 
						|
  });
 | 
						|
}
 |