Feat : tag system
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user