feat(gui): Projektansicht mit Ordnern, Unterreitern und angehefteten Aufnahmen
Neuer Reiter neben "Aufnahmen": Projektordner bündeln Aufnahmen zweistufig (Ordner + Unterreiter), auf beiden Ebenen anheftbar. Anheften geht aus dem Projekt heraus (Dropdown mit Filter) oder über den 📌-Knopf in der Aufnahmenliste, der Ordner und Ebene abfragt, neue Ordner anlegt und bestehende Anheftungen zum Lösen zeigt. Klick auf eine angeheftete Aufnahme springt in die Aufnahmen-Ansicht. Ein Pin ist eine Referenz, kein Besitz: die Aufnahme bleibt unverändert in der Liste und darf in mehreren Ordnern hängen. project_pins.tab_id ist INT NOT NULL DEFAULT 0 (0 = Ordner-Ebene) statt NULL, weil MariaDB NULL von UNIQUE-Indizes ausschließt — sonst ließe sich dieselbe Aufnahme mehrfach auf dieselbe Ebene anheften. So macht INSERT IGNORE das Anheften idempotent. purgeRecording räumt project_pins mit ab; getProject blendet Aufnahmen im Papierkorb aus, ohne deren Pin zu löschen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
963ce427da
commit
772385713e
@@ -81,6 +81,119 @@ export async function ensureSchema() {
|
||||
vec MEDIUMTEXT NULL,
|
||||
INDEX rec_idx (recording_id),
|
||||
FULLTEXT KEY ft_text (text)) ENGINE=InnoDB`);
|
||||
// Projektansicht: Ordner (projects) > Unterreiter (project_tabs) > angepinnte
|
||||
// Aufnahmen (project_pins). Eine Aufnahme kann in beliebig vielen Projekten
|
||||
// hängen — ein Pin ist eine Referenz, kein Besitz, die Aufnahme selbst bleibt
|
||||
// in der normalen Liste unverändert.
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS project_tabs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NOT NULL,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX proj_idx (project_id))`);
|
||||
// tab_id = 0 heißt „direkt im Ordner“ (Ebene 1). Bewusst 0 statt NULL: in
|
||||
// MySQL/MariaDB ignoriert ein UNIQUE-Index NULL-Werte, ein und dieselbe
|
||||
// Aufnahme ließe sich sonst beliebig oft auf die Ordner-Ebene pinnen.
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS project_pins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT NOT NULL,
|
||||
tab_id INT NOT NULL DEFAULT 0,
|
||||
recording_id INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_pin (project_id, tab_id, recording_id),
|
||||
INDEX rec_idx (recording_id))`);
|
||||
}
|
||||
|
||||
// --- Projektansicht ----------------------------------------------------------
|
||||
// Ordnerliste fürs Übersichtsgitter: pro Ordner die Zahl der Unterreiter und
|
||||
// der (nicht gelöschten) Pins über alle Ebenen.
|
||||
export async function listProjects() {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT p.id, p.name, p.created_at,
|
||||
(SELECT COUNT(*) FROM project_tabs t WHERE t.project_id = p.id) AS tabs,
|
||||
(SELECT COUNT(*) FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
|
||||
WHERE pin.project_id = p.id AND r.deleted_at IS NULL) AS pins
|
||||
FROM projects p ORDER BY p.name`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createProject(name) {
|
||||
const [res] = await pool.execute('INSERT INTO projects (name) VALUES (?)', [String(name).slice(0, 190)]);
|
||||
return { id: res.insertId, name };
|
||||
}
|
||||
export async function renameProject(id, name) {
|
||||
await pool.execute('UPDATE projects SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
|
||||
}
|
||||
// Der Ordner verschwindet mitsamt Reitern und Pins — die Aufnahmen selbst
|
||||
// bleiben unberührt (ein Pin ist nur eine Referenz).
|
||||
export async function deleteProject(id) {
|
||||
await pool.query('DELETE FROM project_pins WHERE project_id=?', [id]);
|
||||
await pool.query('DELETE FROM project_tabs WHERE project_id=?', [id]);
|
||||
await pool.query('DELETE FROM projects WHERE id=?', [id]);
|
||||
}
|
||||
|
||||
export async function createProjectTab(projectId, name) {
|
||||
const [res] = await pool.execute('INSERT INTO project_tabs (project_id, name) VALUES (?,?)',
|
||||
[projectId, String(name).slice(0, 190)]);
|
||||
return { id: res.insertId, project_id: projectId, name };
|
||||
}
|
||||
export async function renameProjectTab(id, name) {
|
||||
await pool.execute('UPDATE project_tabs SET name=? WHERE id=?', [String(name).slice(0, 190), id]);
|
||||
}
|
||||
export async function deleteProjectTab(id) {
|
||||
await pool.query('DELETE FROM project_pins WHERE tab_id=?', [id]);
|
||||
await pool.query('DELETE FROM project_tabs WHERE id=?', [id]);
|
||||
}
|
||||
|
||||
// Ein Ordner mit allen Ebenen und den dort angepinnten Aufnahmen. Gelöschte
|
||||
// (im Papierkorb liegende) Aufnahmen werden ausgeblendet, nicht entfernt: eine
|
||||
// Wiederherstellung bringt den Pin ohne Zutun zurück.
|
||||
export async function getProject(id) {
|
||||
const [pr] = await pool.query('SELECT id, name, created_at FROM projects WHERE id=?', [id]);
|
||||
if (!pr.length) return null;
|
||||
const [tabs] = await pool.query(
|
||||
'SELECT id, name, created_at FROM project_tabs WHERE project_id=? ORDER BY id', [id]);
|
||||
const [pins] = await pool.query(`
|
||||
SELECT pin.id AS pin_id, pin.tab_id, r.id, r.title, r.duration_ms, r.created_at,
|
||||
r.status, r.analysis_status, r.summary
|
||||
FROM project_pins pin JOIN recordings r ON r.id = pin.recording_id
|
||||
WHERE pin.project_id=? AND r.deleted_at IS NULL
|
||||
ORDER BY r.created_at DESC`, [id]);
|
||||
return { ...pr[0], tabs, pins };
|
||||
}
|
||||
|
||||
export async function pinRecording(projectId, tabId, recordingId) {
|
||||
await pool.execute(
|
||||
'INSERT IGNORE INTO project_pins (project_id, tab_id, recording_id) VALUES (?,?,?)',
|
||||
[projectId, tabId || 0, recordingId]);
|
||||
}
|
||||
export async function unpinRecording(pinId) {
|
||||
await pool.execute('DELETE FROM project_pins WHERE id=?', [pinId]);
|
||||
}
|
||||
// Wo hängt diese Aufnahme überall? Treibt die Markierung des 📌-Buttons in der
|
||||
// Aufnahmenliste.
|
||||
export async function listPinsForRecording(recId) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT pin.id AS pin_id, pin.project_id, p.name AS project_name,
|
||||
pin.tab_id, t.name AS tab_name
|
||||
FROM project_pins pin
|
||||
JOIN projects p ON p.id = pin.project_id
|
||||
LEFT JOIN project_tabs t ON t.id = pin.tab_id
|
||||
WHERE pin.recording_id=? ORDER BY p.name, t.name`, [recId]);
|
||||
return rows;
|
||||
}
|
||||
// Alle Pins auf einen Schlag (Liste: welche Aufnahme steckt in wie vielen
|
||||
// Projekten) — spart einen Request pro Zeile.
|
||||
export async function pinCounts() {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT recording_id, COUNT(*) AS n FROM project_pins GROUP BY recording_id');
|
||||
const m = {};
|
||||
for (const r of rows) m[r.recording_id] = r.n;
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- live speaker markers ---------------------------------------------------
|
||||
@@ -331,7 +444,7 @@ export async function listTrash() {
|
||||
export async function purgeRecording(id) {
|
||||
const [rows] = await pool.query('SELECT mp3_file, wav_file FROM recordings WHERE id=?', [id]);
|
||||
const files = rows[0] ? [rows[0].mp3_file, rows[0].wav_file].filter(Boolean) : [];
|
||||
for (const t of ['markers', 'words', 'passages'])
|
||||
for (const t of ['markers', 'words', 'passages', 'project_pins'])
|
||||
await pool.query(`DELETE FROM ${t} WHERE recording_id=?`, [id]).catch(() => {});
|
||||
await pool.query('DELETE FROM recordings WHERE id=?', [id]);
|
||||
return files;
|
||||
|
||||
Reference in New Issue
Block a user