feat: add tag editor API endpoint and improve tag editing UI
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 !');
|
||||
Reference in New Issue
Block a user