generated from Dicken/dickendock
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
import { existsSync, mkdirSync } from "node:fs";
|
|
import { dirname } from "node:path";
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
import * as schema from "./schema.js";
|
|
|
|
const databasePath = process.env.DATABASE_PATH ?? "./data/launchpad.db";
|
|
|
|
// Stellt sicher, dass das Verzeichnis für die SQLite-Datei existiert
|
|
// (relevant im Docker-Volume /data).
|
|
const dir = dirname(databasePath);
|
|
if (dir !== "." && !existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
const sqlite = new Database(databasePath);
|
|
sqlite.pragma("journal_mode = WAL");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
|
|
export const db = drizzle(sqlite, { schema });
|
|
|
|
/**
|
|
* Legt die Tabellen an, falls sie noch nicht existieren.
|
|
* Für den ersten Commit reicht ein einfaches "CREATE TABLE IF NOT EXISTS",
|
|
* spätere Commits ersetzen dies durch echte Drizzle-Migrationen (drizzle-kit).
|
|
*/
|
|
export function ensureSchema(): void {
|
|
sqlite.exec(`
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id TEXT PRIMARY KEY,
|
|
hostname TEXT NOT NULL,
|
|
ip TEXT NOT NULL,
|
|
mac TEXT,
|
|
manufacturer TEXT,
|
|
model TEXT,
|
|
online INTEGER NOT NULL DEFAULT 0,
|
|
source TEXT NOT NULL,
|
|
last_scan TEXT,
|
|
last_ping TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS services (
|
|
id TEXT PRIMARY KEY,
|
|
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
display_name TEXT NOT NULL,
|
|
category TEXT,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
"order" REAL NOT NULL DEFAULT 0,
|
|
alias TEXT NOT NULL DEFAULT '[]',
|
|
icon TEXT,
|
|
hostname TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
https INTEGER NOT NULL DEFAULT 0,
|
|
visible INTEGER NOT NULL DEFAULT 1,
|
|
display_name_edited_manually INTEGER NOT NULL DEFAULT 0,
|
|
category_edited_manually INTEGER NOT NULL DEFAULT 0,
|
|
last_suggested_display_name TEXT,
|
|
last_suggested_category TEXT,
|
|
port INTEGER NOT NULL,
|
|
favicon TEXT,
|
|
description TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
"order" REAL NOT NULL DEFAULT 0,
|
|
color TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS scan_logs (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
target_id TEXT,
|
|
level TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
|
id TEXT PRIMARY KEY,
|
|
url TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
hostname TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT,
|
|
icon TEXT,
|
|
favicon TEXT,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
alias TEXT NOT NULL DEFAULT '[]',
|
|
"order" REAL NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS recent_visits (
|
|
id TEXT PRIMARY KEY,
|
|
item_type TEXT NOT NULL,
|
|
item_id TEXT NOT NULL,
|
|
visited_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS read_later (
|
|
id TEXT PRIMARY KEY,
|
|
url TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
favicon TEXT,
|
|
saved_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS detected_apis (
|
|
id TEXT PRIMARY KEY,
|
|
service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
status INTEGER NOT NULL,
|
|
detected_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS scan_dismissals (
|
|
id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
dedupe_key TEXT NOT NULL,
|
|
dismissed_at TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
// Leichte Migration für Datenbanken, die vor Einführung von "visible"/
|
|
// "color" angelegt wurden: CREATE TABLE IF NOT EXISTS rüstet bei bereits
|
|
// existierenden Tabellen keine neuen Spalten nach, das übernehmen wir hier
|
|
// manuell. Bestehende Dienste werden dabei auf sichtbar (1) gesetzt, damit
|
|
// sich am bisherigen Verhalten nichts unerwartet ändert.
|
|
ensureColumn("services", "visible", "INTEGER NOT NULL DEFAULT 1");
|
|
ensureColumn("categories", "color", "TEXT");
|
|
// Für Datenbanken vor Einführung der Unterscheidung "manuell angepasst" vs.
|
|
// "vom Scan abweichend" bei displayName/category (siehe schema.ts). Default
|
|
// 0 (nicht manuell bearbeitet) ist für Bestandsdaten die richtige Annahme.
|
|
ensureColumn("services", "display_name_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
|
ensureColumn("services", "category_edited_manually", "INTEGER NOT NULL DEFAULT 0");
|
|
ensureColumn("services", "last_suggested_display_name", "TEXT");
|
|
ensureColumn("services", "last_suggested_category", "TEXT");
|
|
ensureColumn("devices", "last_ping", "TEXT");
|
|
|
|
deduplicateServicesByDeviceAndPort();
|
|
|
|
// Verhindert künftig auf Datenbankebene, dass zwei Dienste mit demselben
|
|
// (Gerät, Port) gleichzeitig existieren - kann sonst passieren, wenn zwei
|
|
// Scans desselben Geräts überlappen (z. B. "Alle Geräte scannen" läuft
|
|
// noch, während gleichzeitig auch "Jetzt scannen" für genau dieses eine
|
|
// Gerät geklickt wird) und beide parallel einen "existiert noch nicht"-Fund
|
|
// zu einem CREATE statt UPDATE machen. Erst NACH der Bereinigung oben
|
|
// anlegen, sonst würde das Erstellen des Index an vorhandenen Duplikaten
|
|
// scheitern.
|
|
sqlite.exec(
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_services_device_port ON services(device_id, port)`
|
|
);
|
|
|
|
// Verhindert doppelte Einträge für denselben Vorschlag (z. B. wenn zwei
|
|
// Browser fast gleichzeitig denselben neuen Dienst bestätigen) - der
|
|
// Dismiss-Endpunkt selbst nutzt ohnehin "INSERT OR IGNORE" (siehe
|
|
// db/repositories/scanDismissals.ts), dieser Index macht das zusätzlich
|
|
// auf Datenbankebene verbindlich.
|
|
sqlite.exec(
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_dismissals_kind_key ON scan_dismissals(kind, dedupe_key)`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Entfernt doppelte Dienst-Einträge für dasselbe (Gerät, Port) - kann bei
|
|
* älteren Datenbanken vorkommen, wenn zwei überlappende Scans vor Einführung
|
|
* des UNIQUE-Index (s. o.) beide gleichzeitig einen neuen Dienst angelegt
|
|
* haben. Behält den Eintrag mit den meisten ausgefüllten Feldern (Favicon,
|
|
* Kategorie, ...), bei Gleichstand den ältesten (kleinste created_at).
|
|
*/
|
|
function deduplicateServicesByDeviceAndPort(): void {
|
|
const rows = sqlite
|
|
.prepare(`SELECT * FROM services ORDER BY created_at ASC`)
|
|
.all() as Array<Record<string, unknown>>;
|
|
|
|
const groups = new Map<string, Array<Record<string, unknown>>>();
|
|
for (const row of rows) {
|
|
const key = `${row.device_id}::${row.port}`;
|
|
const list = groups.get(key) ?? [];
|
|
list.push(row);
|
|
groups.set(key, list);
|
|
}
|
|
|
|
function richness(row: Record<string, unknown>): number {
|
|
return ["favicon", "category", "description", "icon"].filter((f) => !!row[f]).length;
|
|
}
|
|
|
|
for (const group of groups.values()) {
|
|
if (group.length <= 1) continue;
|
|
|
|
const keeper = [...group].sort((a, b) => richness(b) - richness(a))[0];
|
|
const toDelete = group.filter((r) => r.id !== keeper.id);
|
|
for (const row of toDelete) {
|
|
sqlite.prepare(`DELETE FROM services WHERE id = ?`).run(row.id as string);
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureColumn(table: string, column: string, definition: string): void {
|
|
const existingColumns = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
|
name: string;
|
|
}>;
|
|
const hasColumn = existingColumns.some((col) => col.name === column);
|
|
|
|
if (!hasColumn) {
|
|
sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
}
|
|
}
|