feat: add tag editor API endpoint and improve tag editing UI

This commit is contained in:
2025-10-17 09:14:01 -04:00
parent 4dbcf8ad81
commit e2775a3d43
12 changed files with 1133 additions and 150 deletions
+99
View File
@@ -17,6 +17,7 @@ import {
extractFrontMatter,
isValidExcalidrawScene
} from './excalidraw-obsidian.mjs';
import { rewriteTagsFrontmatter, extractTagsFromFrontmatter } from './markdown-frontmatter.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -1103,6 +1104,104 @@ app.get('/api/search', async (req, res) => {
}
});
// PUT /api/notes/:idOrPath/tags - Update tags for a specific note
// Accepts either a slug id or a vault-relative path (with or without .md), including slashes
app.put(/^\/api\/notes\/(.+?)\/tags$/, express.json(), async (req, res) => {
try {
const rawParam = req.params[0];
const noteParam = decodeURIComponent(rawParam || '');
const { tags } = req.body;
if (!Array.isArray(tags)) {
return res.status(400).json({ error: 'tags must be an array' });
}
// Find note by id or by path
const notes = loadVaultNotes(vaultDir);
let note = notes.find(n => n.id === noteParam);
if (!note) {
// Try by originalPath (without .md)
const withoutExt = noteParam.replace(/\.md$/i, '');
note = notes.find(n => n.originalPath === withoutExt);
}
if (!note) {
// Try by filePath (with .md)
const withExt = /\.md$/i.test(noteParam) ? noteParam : `${noteParam}.md`;
// Normalize slashes
const normalized = withExt.replace(/\\/g, '/');
note = notes.find(n => n.filePath === normalized || n.filePath === normalized.replace(/^\//, ''));
}
if (!note || !note.filePath) {
return res.status(404).json({ error: 'Note not found' });
}
const absolutePath = path.join(vaultDir, note.filePath);
if (!fs.existsSync(absolutePath)) {
return res.status(404).json({ error: 'File not found on disk' });
}
// Read current content
const currentContent = fs.readFileSync(absolutePath, 'utf-8');
// Rewrite with new tags
const updatedContent = rewriteTagsFrontmatter(currentContent, tags);
// Write back to disk (atomic with backup)
const tempPath = absolutePath + '.tmp';
const backupPath = absolutePath + '.bak';
try {
// Create backup
fs.copyFileSync(absolutePath, backupPath);
// Write to temp file
fs.writeFileSync(tempPath, updatedContent, 'utf-8');
// Atomic rename
fs.renameSync(tempPath, absolutePath);
console.log(`[Tags] Updated tags for note ${note.id} (${note.filePath})`);
// Extract final tags from updated content
const finalTags = extractTagsFromFrontmatter(updatedContent);
// Trigger Meilisearch reindex for this file
try {
await upsertFile(note.filePath);
} catch (indexError) {
console.warn('[Tags] Failed to reindex after tag update:', indexError);
}
res.json({
ok: true,
tags: finalTags,
noteId: note.id
});
} catch (writeError) {
// Restore from backup on error
if (fs.existsSync(tempPath)) {
try { fs.unlinkSync(tempPath); } catch {}
}
if (fs.existsSync(backupPath)) {
try { fs.copyFileSync(backupPath, absolutePath); } catch {}
}
throw writeError;
}
} catch (error) {
console.error('[Tags] Update failed:', error);
res.status(500).json({
error: 'Failed to update tags',
message: error.message
});
}
});
app.post('/api/reindex', async (_req, res) => {
try {
console.log('[Meili] Manual reindex triggered');
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Markdown front-matter utilities for tag management
* Handles YAML front-matter parsing and serialization with strict business rules
*/
/**
* Normalise un tag : trim, normalise les espaces
*/
function normalizeTag(tag) {
return String(tag || '').trim().replace(/\s+/g, ' ');
}
/**
* Déduplique les tags (case-insensitive) en préservant la première occurrence
*/
function deduplicateTags(tags) {
const seen = new Set();
const result = [];
for (const tag of tags) {
const normalized = normalizeTag(tag);
if (!normalized) continue;
const lower = normalized.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
result.push(normalized);
}
}
return result;
}
/**
* Réécrit le front-matter YAML d'un fichier Markdown avec les tags fournis.
*
* Règles métier :
* - Crée la section --- ... --- si absente
* - Normalise et déduplique les tags (case-insensitive)
* - Si liste vide, supprime la clé tags: (ne laisse pas tags: [])
* - Aucune ligne vide dans le front-matter résultant
* - Préserve l'ordre et le format des autres propriétés
*
* @param {string} rawMarkdown - Contenu Markdown brut
* @param {string[]} tags - Liste des tags à appliquer
* @returns {string} - Contenu Markdown mis à jour
*/
export function rewriteTagsFrontmatter(rawMarkdown, tags) {
// Normaliser le contenu (BOM, line endings)
const content = rawMarkdown.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
// Détecter le front-matter existant
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
// Normaliser et dédupliquer les tags
const cleanTags = deduplicateTags(tags || []);
// Construire le bloc tags (format YAML liste)
const buildTagsBlock = (tagList) => {
if (tagList.length === 0) return ''; // Ne pas créer de clé tags si vide
return 'tags:\n' + tagList.map(t => ` - ${t}`).join('\n');
};
const newTagsBlock = buildTagsBlock(cleanTags);
// Cas 1 : Pas de front-matter existant
if (!fmMatch) {
if (cleanTags.length === 0) {
// Pas de tags, pas de front-matter à créer
return content;
}
// Créer un nouveau front-matter avec les tags
return `---\n${newTagsBlock}\n---\n${content}`;
}
// Cas 2 : Front-matter existant
const fmText = fmMatch[1];
const body = fmMatch[2] || '';
// Supprimer l'ancien bloc tags (format liste ou inline)
const tagsRe = /(^|\n)tags\s*:[^\n]*(?:\n\s+-\s+[^\n]*)*(?=\n|$)/i;
let updatedFm = fmText.replace(tagsRe, '');
// Nettoyer les lignes vides multiples
updatedFm = updatedFm.replace(/\n{2,}/g, '\n').trim();
// Ajouter le nouveau bloc tags si non vide
if (newTagsBlock) {
updatedFm = updatedFm ? `${updatedFm}\n${newTagsBlock}` : newTagsBlock;
}
// Si le front-matter est vide après suppression, ne pas créer de section vide
if (!updatedFm.trim()) {
return body;
}
// Reconstruire le document
return `---\n${updatedFm}\n---\n${body}`;
}
/**
* Extrait les tags du front-matter YAML
* @param {string} rawMarkdown - Contenu Markdown brut
* @returns {string[]} - Liste des tags trouvés
*/
export function extractTagsFromFrontmatter(rawMarkdown) {
const content = rawMarkdown.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return [];
const fmText = fmMatch[1];
// Chercher le bloc tags (format liste)
const tagsListMatch = fmText.match(/^tags:\s*\n((?:\s+-\s+.+\n?)+)/m);
if (tagsListMatch) {
const lines = tagsListMatch[1].split('\n').filter(Boolean);
return lines.map(line => line.replace(/^\s*-\s*/, '').trim()).filter(Boolean);
}
// Chercher format inline (tags: [tag1, tag2])
const tagsInlineMatch = fmText.match(/^tags:\s*\[(.*?)\]/m);
if (tagsInlineMatch) {
return tagsInlineMatch[1]
.split(',')
.map(t => t.trim())
.filter(Boolean);
}
return [];
}
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* Tests unitaires pour markdown-frontmatter.mjs
* Valide toutes les règles métier YAML
*/
import assert from 'assert';
import { rewriteTagsFrontmatter, extractTagsFromFrontmatter } from './markdown-frontmatter.mjs';
function test(name, fn) {
try {
fn();
console.log(`✅ ${name}`);
} catch (error) {
console.error(`❌ ${name}`);
console.error(error);
process.exit(1);
}
}
console.log('🧪 Tests markdown-frontmatter\n');
// Test 1: Création de front-matter si absent
test('Crée front-matter avec tags si absent', () => {
const input = 'Hello world';
const result = rewriteTagsFrontmatter(input, ['tag1', 'tag2']);
assert.ok(result.includes('---'));
assert.ok(result.includes('tags:'));
assert.ok(result.includes(' - tag1'));
assert.ok(result.includes(' - tag2'));
assert.ok(result.includes('Hello world'));
});
// Test 2: Pas de front-matter si tags vide
test('Ne crée pas de front-matter si tags vide et pas de FM existant', () => {
const input = 'Hello world';
const result = rewriteTagsFrontmatter(input, []);
assert.strictEqual(result, 'Hello world');
});
// Test 3: Suppression de la clé tags si liste vide
test('Supprime la clé tags si liste vide', () => {
const input = `---
title: Test
tags:
- old-tag
---
Content`;
const result = rewriteTagsFrontmatter(input, []);
assert.ok(result.includes('title: Test'));
assert.ok(!result.includes('tags:'));
assert.ok(!result.includes('old-tag'));
});
// Test 4: Déduplique les tags (case-insensitive)
test('Déduplique les tags (case-insensitive)', () => {
const input = 'Hello';
const result = rewriteTagsFrontmatter(input, ['Tag', 'tag', 'TAG', 'other']);
const tags = extractTagsFromFrontmatter(result);
assert.strictEqual(tags.length, 2);
assert.ok(tags.includes('Tag')); // Première occurrence préservée
assert.ok(tags.includes('other'));
});
// Test 5: Normalise les espaces
test('Normalise les espaces dans les tags', () => {
const input = 'Hello';
const result = rewriteTagsFrontmatter(input, [' tag with spaces ']);
const tags = extractTagsFromFrontmatter(result);
assert.strictEqual(tags.length, 1);
assert.strictEqual(tags[0], 'tag with spaces');
});
// Test 6: Préserve les autres propriétés
test('Préserve les autres propriétés du front-matter', () => {
const input = `---
title: My Note
author: John
date: 2024-01-01
tags:
- old
---
Content`;
const result = rewriteTagsFrontmatter(input, ['new']);
assert.ok(result.includes('title: My Note'));
assert.ok(result.includes('author: John'));
assert.ok(result.includes('date: 2024-01-01'));
assert.ok(result.includes(' - new'));
assert.ok(!result.includes(' - old'));
});
// Test 7: Pas de lignes vides dans le front-matter
test('Pas de lignes vides dans le front-matter', () => {
const input = `---
title: Test
tags:
- old
---
Content`;
const result = rewriteTagsFrontmatter(input, ['new']);
const fmMatch = result.match(/^---\n([\s\S]*?)\n---/);
assert.ok(fmMatch);
const fm = fmMatch[1];
assert.ok(!fm.includes('\n\n')); // Pas de double saut de ligne
});
// Test 8: Supprime le front-matter si vide après suppression des tags
test('Supprime le front-matter si vide après suppression des tags', () => {
const input = `---
tags:
- only-tag
---
Content`;
const result = rewriteTagsFrontmatter(input, []);
assert.strictEqual(result, 'Content');
assert.ok(!result.includes('---'));
});
// Test 9: Gère les tags inline (format [tag1, tag2])
test('Remplace les tags inline par format liste', () => {
const input = `---
title: Test
tags: [old1, old2]
---
Content`;
const result = rewriteTagsFrontmatter(input, ['new1', 'new2']);
assert.ok(result.includes(' - new1'));
assert.ok(result.includes(' - new2'));
assert.ok(!result.includes('[old1, old2]'));
});
// Test 10: Extraction des tags
test('Extrait correctement les tags du front-matter', () => {
const input = `---
title: Test
tags:
- tag1
- tag2
- tag3
---
Content`;
const tags = extractTagsFromFrontmatter(input);
assert.strictEqual(tags.length, 3);
assert.deepStrictEqual(tags, ['tag1', 'tag2', 'tag3']);
});
// Test 11: Extraction des tags inline
test('Extrait les tags inline', () => {
const input = `---
tags: [tag1, tag2, tag3]
---
Content`;
const tags = extractTagsFromFrontmatter(input);
assert.strictEqual(tags.length, 3);
assert.deepStrictEqual(tags, ['tag1', 'tag2', 'tag3']);
});
// Test 12: Gère les caractères spéciaux
test('Gère les tags avec caractères spéciaux', () => {
const input = 'Content';
const result = rewriteTagsFrontmatter(input, ['tag-with-dash', 'tag_with_underscore', 'tag/with/slash']);
const tags = extractTagsFromFrontmatter(result);
assert.strictEqual(tags.length, 3);
assert.ok(tags.includes('tag-with-dash'));
assert.ok(tags.includes('tag_with_underscore'));
assert.ok(tags.includes('tag/with/slash'));
});
// Test 13: Gère les BOM et line endings
test('Normalise BOM et line endings', () => {
const input = '\uFEFFContent\r\nwith\r\nCRLF';
const result = rewriteTagsFrontmatter(input, ['tag']);
assert.ok(!result.includes('\uFEFF'));
assert.ok(!result.includes('\r'));
});
console.log('\n✅ Tous les tests passent !');