Feat : tag system
All checks were successful
release / build (push) Successful in 26s
release / verify-windows (push) Successful in 1m26s

This commit is contained in:
jeanotx32
2026-08-12 22:19:16 +02:00
parent cbbf31cabf
commit d44a7c4fb6
10 changed files with 840 additions and 6 deletions

View File

@@ -8,12 +8,13 @@ import {
normalizeBrowserSettings,
normalizePushoverSettings,
normalizeRecordingSettings,
normalizeTagName,
normalizeWatchSettings,
parseStripchatUsername,
} from '@stream-control/shared';
import { config } from './config.ts';
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, targetsRepo } from './db.ts';
import { agentsRepo, logsRepo, sessionsRepo, spansRepo, tagsRepo, targetsRepo } from './db.ts';
import { hub } from './hub.ts';
import { previewFileFor } from './previews.ts';
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
@@ -289,6 +290,16 @@ api.patch('/watchlist/:id', (req, res) => {
preempt: typeof req.body?.preempt === 'boolean' ? req.body.preempt : target.preempt,
});
// Remplacement en bloc, et seulement si le champ est présent : une requête qui
// ne parle pas d'étiquettes (basculer un favori, par exemple) ne doit pas les
// effacer au passage.
if (Array.isArray(req.body?.tagIds)) {
tagsRepo.setForTarget(
target.id,
(req.body.tagIds as unknown[]).filter((id): id is string => typeof id === 'string'),
);
}
const updated = targetsRepo.get(target.id);
if (!updated) {
res.status(500).json({ error: 'Mise à jour impossible' });
@@ -298,6 +309,70 @@ api.patch('/watchlist/:id', (req, res) => {
res.json({ target: updated });
});
// --- Étiquettes ---------------------------------------------------------------
api.get('/tags', (_req, res) => {
res.json({ tags: tagsRepo.list() });
});
/**
* Crée une étiquette, ou renvoie celle qui porte déjà ce nom.
*
* Pas de 409 sur un nom déjà pris : côté dashboard, poser une étiquette se fait
* en la saisissant, et rien ne distingue à la frappe un nom neuf d'un nom
* existant. Refuser le second obligerait l'interface à vérifier avant chaque
* ajout pour un résultat identique.
*/
api.post('/tags', (req, res) => {
const name = normalizeTagName(req.body?.name);
if (!name) {
res.status(400).json({ error: 'Nom d\'étiquette vide' });
return;
}
res.status(201).json({ tag: tagsRepo.ensure(randomUUID(), name) });
});
api.patch('/tags/:id', (req, res) => {
const tag = tagsRepo.get(req.params.id);
if (!tag) {
res.status(404).json({ error: 'Étiquette introuvable' });
return;
}
const name = normalizeTagName(req.body?.name);
if (!name) {
res.status(400).json({ error: 'Nom d\'étiquette vide' });
return;
}
// Le renommage, lui, refuse la collision : fusionner deux étiquettes n'est pas
// ce qu'on demande en corrigeant une faute de frappe, et le faire en silence
// ferait disparaître l'une des deux des profils qui la portaient.
const clash = tagsRepo.findByName(name);
if (clash && clash.id !== tag.id) {
res.status(409).json({ error: `« ${name} » existe déjà` });
return;
}
tagsRepo.rename(tag.id, name);
// Les profils portent leurs étiquettes en clair : sans republication, les
// fiches déjà à l'écran garderaient l'ancien nom jusqu'au prochain rechargement.
for (const target of targetsRepo.list()) hub.publishTarget(target);
res.json({ tag: { id: tag.id, name } });
});
api.delete('/tags/:id', (req, res) => {
const tag = tagsRepo.get(req.params.id);
if (!tag) {
res.status(404).json({ error: 'Étiquette introuvable' });
return;
}
// Les rattachements partent en cascade : l'étiquette disparaît de tous les
// profils qui la portaient, sans que ceux-ci soient touchés autrement.
tagsRepo.remove(tag.id);
for (const target of targetsRepo.list()) hub.publishTarget(target);
res.json({ ok: true });
});
api.delete('/watchlist/:id', (req, res) => {
const target = targetsRepo.get(req.params.id);
if (!target) {

View File

@@ -11,6 +11,7 @@ import type {
RecordingSpan,
StreamSession,
StreamState,
Tag,
BrowserSettings,
WatchSettings,
WatchTarget,
@@ -114,6 +115,31 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_spans_started ON recording_spans (started_at DESC);
CREATE INDEX IF NOT EXISTS idx_spans_open ON recording_spans (agent_id) WHERE ended_at IS NULL;
-- Étiquettes libres, posables sur autant de profils qu'on veut. Table à part
-- plutôt qu'un texte répété sur chaque profil : c'est ce qui rend le renommage
-- possible d'un geste, et garde une étiquette disponible même quand plus aucun
-- profil ne la porte.
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- NOCASE : « Blonde » et « blonde » sont la même étiquette. Sans cet index,
-- une majuscule d'inattention en créerait une seconde, indiscernable de la
-- première dans la liste des filtres.
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_name ON tags (name COLLATE NOCASE);
-- Le double CASCADE est voulu : ni un profil qu'on ne suit plus, ni une
-- étiquette supprimée ne doivent laisser de rattachement derrière eux.
CREATE TABLE IF NOT EXISTS target_tags (
target_id TEXT NOT NULL REFERENCES watch_targets (id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
PRIMARY KEY (target_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_target_tags_tag ON target_tags (tag_id);
-- Réglages globaux, hors agents et profils : une ligne par clé, en JSON.
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
-- table à chaque fois.
@@ -495,8 +521,126 @@ interface TargetRow {
const num = (value: number | null): number | null => (value === null ? null : Number(value));
function toTarget(row: TargetRow): WatchTarget {
// --- Étiquettes ---------------------------------------------------------------
const tagStmts = {
list: db.prepare('SELECT id, name FROM tags ORDER BY name COLLATE NOCASE'),
get: db.prepare('SELECT id, name FROM tags WHERE id = ?'),
getByName: db.prepare('SELECT id, name FROM tags WHERE name = ? COLLATE NOCASE'),
insert: db.prepare('INSERT INTO tags (id, name, created_at) VALUES (?, ?, ?)'),
rename: db.prepare('UPDATE tags SET name = ? WHERE id = ?'),
remove: db.prepare('DELETE FROM tags WHERE id = ?'),
forTarget: db.prepare(`
SELECT t.id, t.name FROM tags t
JOIN target_tags tt ON tt.tag_id = t.id
WHERE tt.target_id = ?
ORDER BY t.name COLLATE NOCASE
`),
all: db.prepare(`
SELECT tt.target_id, t.id, t.name FROM target_tags tt
JOIN tags t ON t.id = tt.tag_id
ORDER BY t.name COLLATE NOCASE
`),
clearForTarget: db.prepare('DELETE FROM target_tags WHERE target_id = ?'),
attach: db.prepare('INSERT OR IGNORE INTO target_tags (target_id, tag_id) VALUES (?, ?)'),
};
export const tagsRepo = {
list(): Tag[] {
return tagStmts.list.all() as unknown as Tag[];
},
get(id: string): Tag | null {
return (tagStmts.get.get(id) as unknown as Tag | undefined) ?? null;
},
findByName(name: string): Tag | null {
return (tagStmts.getByName.get(name) as unknown as Tag | undefined) ?? null;
},
/**
* Crée l'étiquette, ou renvoie celle qui porte déjà ce nom.
*
* Idempotent à dessein : côté dashboard, poser une étiquette se fait en la
* saisissant, et rien ne distingue à la frappe un nom neuf d'un nom existant.
* Échouer sur le second obligerait l'interface à vérifier avant chaque ajout.
*/
ensure(id: string, name: string): Tag {
const existing = tagsRepo.findByName(name);
if (existing) return existing;
tagStmts.insert.run(id, name, Date.now());
return { id, name };
},
rename(id: string, name: string): void {
tagStmts.rename.run(name, id);
},
remove(id: string): void {
tagStmts.remove.run(id);
},
forTarget(targetId: string): Tag[] {
return tagStmts.forTarget.all(targetId) as unknown as Tag[];
},
/** Toutes les affectations d'un coup, pour éviter une requête par profil. */
byTarget(): Map<string, Tag[]> {
const rows = tagStmts.all.all() as unknown as Array<{
target_id: string;
id: string;
name: string;
}>;
const map = new Map<string, Tag[]>();
for (const row of rows) {
const bucket = map.get(row.target_id);
const tag = { id: row.id, name: row.name };
if (bucket) bucket.push(tag);
else map.set(row.target_id, [tag]);
}
return map;
},
/**
* Remplace en bloc les étiquettes d'un profil.
*
* En transaction : l'opération efface avant de réécrire, et une interruption
* entre les deux laisserait le profil dépouillé de toutes ses étiquettes
* plutôt qu'inchangé.
*/
setForTarget(targetId: string, tagIds: string[]): void {
db.exec('BEGIN');
try {
tagStmts.clearForTarget.run(targetId);
for (const tagId of tagIds) {
// Un identifiant périmé (étiquette supprimée entre l'affichage et
// l'envoi) est écarté sans faire échouer les autres : la clé étrangère
// le refuse, on passe au suivant.
try {
tagStmts.attach.run(targetId, tagId);
} catch {
// Étiquette disparue : rien à rattacher.
}
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
};
/**
* Étiquettes d'un profil, ou celles fournies d'avance.
*
* Le paramètre existe pour `list()`, qui les charge toutes en une requête et les
* distribue ensuite : sans lui, afficher trente profils en déclencherait trente,
* une par ligne.
*/
function toTarget(row: TargetRow, tags?: Tag[]): WatchTarget {
return {
tags: tags ?? tagsRepo.forTarget(row.id),
id: row.id,
provider: (row.provider as WatchTarget['provider']) ?? 'stripchat',
username: row.username,
@@ -550,7 +694,10 @@ const targetStmts = {
export const targetsRepo = {
list(): WatchTarget[] {
return (targetStmts.list.all() as unknown as TargetRow[]).map(toTarget);
const tags = tagsRepo.byTarget();
return (targetStmts.list.all() as unknown as TargetRow[]).map((row) =>
toTarget(row, tags.get(row.id) ?? []),
);
},
get(id: string): WatchTarget | null {