feat: add note context menu with color and metadata controls

- Added PATCH endpoint for updating note frontmatter with YAML parsing and merging
- Added DELETE endpoint to move notes to .trash directory with timestamped filenames
- Implemented note context menu with actions like duplicate, share, favorite, and delete
- Added color picker to context menu with gradient background visualization
- Extended NoteFrontmatter type with readOnly and color properties
- Added YAML frontmatter parser with support
This commit is contained in:
2025-10-24 13:45:02 -04:00
parent 83603e2d97
commit 0f7cc552ca
10 changed files with 1437 additions and 4 deletions
+167 -2
View File
@@ -537,8 +537,8 @@ export async function setupDeferredIndexing(vaultDir, fullReindex) {
};
}
import { join, dirname, relative } from 'path';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { join, dirname, relative, basename } from 'path';
import { existsSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
// ============================================================================
// ENDPOINT: POST /api/vault/notes - Create new note
@@ -629,3 +629,168 @@ export function setupCreateNoteEndpoint(app, vaultDir) {
}
});
}
// 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 /api/vault/notes/:id endpoint');
app.patch('/api/vault/notes/:id', async (req, res) => {
try {
const { id } = req.params;
const { frontmatter } = req.body;
console.log('[/api/vault/notes/:id] 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/:id] Updated note: ${id}`);
res.json({
id,
success: true,
frontmatter: updatedFrontmatter
});
} catch (error) {
console.error('[/api/vault/notes/:id] 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 DELETE /api/vault/notes/:id endpoint');
app.delete('/api/vault/notes/:id', async (req, res) => {
try {
const { id } = req.params;
console.log('[/api/vault/notes/:id] 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/:id] Moved note to trash: ${id} -> ${trashFileName}`);
res.json({
id,
success: true,
trashPath: relative(vaultDir, trashPath).replace(/\\/g, '/')
});
} catch (error) {
console.error('[/api/vault/notes/:id] Error deleting note:', error.message, error.stack);
res.status(500).json({ error: 'Failed to delete note', details: error.message });
}
});
}
+6
View File
@@ -31,6 +31,8 @@ import {
setupPerformanceEndpoint,
setupDeferredIndexing,
setupCreateNoteEndpoint,
setupUpdateNoteEndpoint,
setupDeleteNoteEndpoint,
setupRenameFolderEndpoint,
setupDeleteFolderEndpoint,
setupCreateFolderEndpoint
@@ -1513,6 +1515,10 @@ setupPerformanceEndpoint(app, performanceMonitor, metadataCache, meilisearchCirc
// Setup create note endpoint (must be before catch-all)
setupCreateNoteEndpoint(app, vaultDir);
// Setup update and delete note endpoints (must be before catch-all)
setupUpdateNoteEndpoint(app, vaultDir);
setupDeleteNoteEndpoint(app, vaultDir);
// SSE endpoint for vault events (folder rename, delete, etc.)
app.get('/api/vault/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');