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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user