generated from Dicken/dickendock
Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung
This commit is contained in:
@@ -26,15 +26,19 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
let displayName = parsed.data.displayName;
|
||||
let favicon: string | null = null;
|
||||
let description = parsed.data.description;
|
||||
|
||||
// Titel/Favicon automatisch ziehen, falls kein Name angegeben wurde oder
|
||||
// schlicht um ein Favicon zu bekommen (derselbe Mechanismus wie beim
|
||||
// Netzwerk-Scanner, siehe apps/backend/src/scanner/http.ts).
|
||||
// Titel/Favicon/Beschreibung automatisch ziehen, falls nicht angegeben
|
||||
// (derselbe Mechanismus wie beim Netzwerk-Scanner, siehe
|
||||
// apps/backend/src/scanner/http.ts).
|
||||
try {
|
||||
const probe = await probeHttp(parsed.data.url, 5000);
|
||||
if (!displayName) {
|
||||
displayName = probe.title ?? bookmarkRepo.extractHostname(parsed.data.url);
|
||||
}
|
||||
if (!description) {
|
||||
description = probe.description;
|
||||
}
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
if (!displayName) {
|
||||
@@ -46,7 +50,10 @@ export async function bookmarkRoutes(app: FastifyInstance): Promise<void> {
|
||||
categoryRepo.ensureCategory(parsed.data.category);
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(parsed.data, { displayName, favicon });
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ ...parsed.data, description },
|
||||
{ displayName, favicon }
|
||||
);
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
|
||||
|
||||
71
apps/backend/src/routes/readLater.ts
Normal file
71
apps/backend/src/routes/readLater.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as readLaterRepo from "../db/repositories/readLater.js";
|
||||
import * as bookmarkRepo from "../db/repositories/bookmarks.js";
|
||||
import { probeHttp } from "../scanner/http.js";
|
||||
|
||||
export async function readLaterRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/read-later", async () => {
|
||||
return readLaterRepo.listReadLater();
|
||||
});
|
||||
|
||||
app.post("/api/read-later", async (request, reply) => {
|
||||
const body = request.body as { url?: string } | undefined;
|
||||
if (!body?.url) {
|
||||
return reply.code(400).send({ error: "url erforderlich" });
|
||||
}
|
||||
|
||||
let name: string;
|
||||
let favicon: string | null = null;
|
||||
try {
|
||||
const probe = await probeHttp(body.url, 5000);
|
||||
name = probe.title ?? new URL(body.url).hostname;
|
||||
favicon = probe.faviconUrl ?? null;
|
||||
} catch {
|
||||
try {
|
||||
name = new URL(body.url).hostname;
|
||||
} catch {
|
||||
name = body.url;
|
||||
}
|
||||
}
|
||||
|
||||
const item = readLaterRepo.createReadLaterItem({ url: body.url, displayName: name, favicon });
|
||||
return reply.code(201).send(item);
|
||||
});
|
||||
|
||||
app.patch("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = request.body as { url?: string; displayName?: string } | undefined;
|
||||
const item = readLaterRepo.updateReadLaterItem(id, body ?? {});
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
app.delete("/api/read-later/:id", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = readLaterRepo.deleteReadLaterItem(id);
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// Verschiebt einen "Später lesen"-Eintrag in die Lesezeichen (als Favorit)
|
||||
// und entfernt ihn aus der Liste.
|
||||
app.post("/api/read-later/:id/promote", async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = readLaterRepo.getReadLaterItem(id);
|
||||
if (!item) {
|
||||
return reply.code(404).send({ error: "Eintrag nicht gefunden" });
|
||||
}
|
||||
|
||||
const bookmark = bookmarkRepo.createBookmark(
|
||||
{ url: item.url, favorite: true },
|
||||
{ displayName: item.displayName, favicon: item.favicon }
|
||||
);
|
||||
readLaterRepo.deleteReadLaterItem(id);
|
||||
|
||||
return reply.code(201).send(bookmark);
|
||||
});
|
||||
}
|
||||
27
apps/backend/src/routes/recentVisits.ts
Normal file
27
apps/backend/src/routes/recentVisits.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as recentVisitsRepo from "../db/repositories/recentVisits.js";
|
||||
|
||||
export async function recentVisitsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/recent-visits", async (request) => {
|
||||
const query = request.query as { limit?: string };
|
||||
const limit = query.limit ? Number(query.limit) : undefined;
|
||||
return recentVisitsRepo.listRecentVisits(limit);
|
||||
});
|
||||
|
||||
app.post("/api/recent-visits", async (request, reply) => {
|
||||
const body = request.body as { itemType?: string; itemId?: string } | undefined;
|
||||
if (body?.itemType !== "service" && body?.itemType !== "bookmark") {
|
||||
return reply.code(400).send({ error: "itemType muss 'service' oder 'bookmark' sein" });
|
||||
}
|
||||
if (!body.itemId) {
|
||||
return reply.code(400).send({ error: "itemId erforderlich" });
|
||||
}
|
||||
recentVisitsRepo.recordVisit(body.itemType, body.itemId);
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/recent-visits", async () => {
|
||||
recentVisitsRepo.clearRecentVisits();
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
|
||||
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||
|
||||
const discovered = await scanDeviceServices(device);
|
||||
|
||||
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
|
||||
@@ -60,11 +64,17 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
const updated = results.filter((r) => !r.created).length;
|
||||
const ports = discovered.map((d) => d.port).sort((a, b) => a - b);
|
||||
|
||||
// Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden
|
||||
// (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern
|
||||
// zur manuellen Durchsicht zurückgegeben (siehe Admin -> Scanner).
|
||||
const foundPorts = new Set(discovered.map((d) => d.port));
|
||||
const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port));
|
||||
|
||||
logRepo.logScan({
|
||||
type: "device",
|
||||
targetId: device.id,
|
||||
level: "info",
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert`,
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}`,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -74,6 +84,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
created,
|
||||
updated,
|
||||
services: results.map((r) => r.service),
|
||||
staleServices,
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
@@ -106,6 +117,8 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
|
||||
|
||||
try {
|
||||
const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox");
|
||||
|
||||
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
|
||||
const devices = hosts.map((h) =>
|
||||
deviceRepo.upsertDeviceFromScan({
|
||||
@@ -117,13 +130,19 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
);
|
||||
|
||||
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||
// mehr in der Liste sind – nicht automatisch gelöscht, nur zur
|
||||
// manuellen Durchsicht zurückgegeben.
|
||||
const foundIps = new Set(hosts.map((h) => h.ip));
|
||||
const staleDevices = devicesBeforeScan.filter((d) => !foundIps.has(d.ip));
|
||||
|
||||
logRepo.logScan({
|
||||
type: "fritzbox",
|
||||
level: "info",
|
||||
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden`,
|
||||
message: `FritzBox-Scan: ${hosts.length} Gerät(e) gefunden${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}`,
|
||||
});
|
||||
|
||||
return { found: hosts.length, devices };
|
||||
return { found: hosts.length, devices, staleDevices };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
logRepo.logScan({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as XLSX from "xlsx";
|
||||
import { ServiceCreateSchema, ServiceReorderSchema, ServiceUpdateSchema } from "@launchpad/shared";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
|
||||
interface ServiceListQuery {
|
||||
deviceId?: string;
|
||||
@@ -11,45 +9,6 @@ interface ServiceListQuery {
|
||||
favorite?: string;
|
||||
}
|
||||
|
||||
interface ExportRow {
|
||||
displayName: string;
|
||||
category: string;
|
||||
alias: string;
|
||||
favorite: string;
|
||||
visible: string;
|
||||
order: number;
|
||||
hostname: string;
|
||||
port: number;
|
||||
https: string;
|
||||
url: string;
|
||||
deviceHostname: string;
|
||||
deviceIp: string;
|
||||
}
|
||||
|
||||
function buildExportRows(): ExportRow[] {
|
||||
const services = serviceRepo.listServices();
|
||||
const devices = deviceRepo.listDevices();
|
||||
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||
|
||||
return services.map((s) => {
|
||||
const device = deviceById.get(s.deviceId);
|
||||
return {
|
||||
displayName: s.displayName,
|
||||
category: s.category ?? "",
|
||||
alias: s.alias.join(";"),
|
||||
favorite: s.favorite ? "true" : "false",
|
||||
visible: s.visible ? "true" : "false",
|
||||
order: s.order,
|
||||
hostname: s.hostname,
|
||||
port: s.port,
|
||||
https: s.https ? "true" : "false",
|
||||
url: s.url,
|
||||
deviceHostname: device?.hostname ?? "",
|
||||
deviceIp: device?.ip ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/services", async (request) => {
|
||||
const query = request.query as ServiceListQuery;
|
||||
@@ -84,136 +43,6 @@ export async function serviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(201).send(service);
|
||||
});
|
||||
|
||||
// Müssen vor der /:id-Route stehen, damit "export"/"import" nicht als ID interpretiert wird.
|
||||
app.get("/api/services/export", async (request, reply) => {
|
||||
const query = request.query as { format?: string };
|
||||
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||
const rows = buildExportRows();
|
||||
|
||||
if (format === "json") {
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.json"');
|
||||
reply.type("application/json");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Dienste");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.xlsx"');
|
||||
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
return reply.send(buffer);
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-services.csv"');
|
||||
reply.type("text/csv; charset=utf-8");
|
||||
return reply.send(csv);
|
||||
});
|
||||
|
||||
app.post("/api/services/import", async (request, reply) => {
|
||||
const body = request.body as { format?: string; content?: string } | undefined;
|
||||
if (!body?.content) {
|
||||
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
if (body.format === "json") {
|
||||
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||
rows = JSON.parse(text);
|
||||
} else {
|
||||
const buffer = Buffer.from(body.content, "base64");
|
||||
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: "Datei konnte nicht gelesen werden",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||
const port = Number(row.port);
|
||||
const displayName = String(row.displayName ?? "").trim();
|
||||
const url = String(row.url ?? "").trim();
|
||||
|
||||
if (!displayName || !hostname || !port || !url) {
|
||||
errors.push(`Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gerät über IP finden, sonst über Hostname, sonst neu anlegen.
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||
if (!device && deviceHostname) {
|
||||
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||
}
|
||||
if (!device) {
|
||||
device = deviceRepo.createDevice({
|
||||
hostname: deviceHostname || hostname,
|
||||
ip: deviceIp || hostname,
|
||||
});
|
||||
}
|
||||
|
||||
// Bereits vorhanden (gleiches Gerät + Port)? -> überspringen, nicht
|
||||
// doppelt anlegen und nichts Bestehendes verändern/löschen.
|
||||
const existing = serviceRepo.listServicesByDevice(device.id).find((s) => s.port === port);
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = row.category ? String(row.category).trim() : undefined;
|
||||
|
||||
serviceRepo.createService({
|
||||
deviceId: device.id,
|
||||
displayName,
|
||||
hostname,
|
||||
url,
|
||||
https: String(row.https ?? "").toLowerCase() === "true",
|
||||
port,
|
||||
category: category || undefined,
|
||||
alias: row.alias
|
||||
? String(row.alias)
|
||||
.split(";")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||
order:
|
||||
row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||
visible:
|
||||
row.visible !== undefined && row.visible !== ""
|
||||
? String(row.visible).toLowerCase() === "true"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (category) {
|
||||
categoryRepo.ensureCategory(category);
|
||||
}
|
||||
|
||||
imported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
});
|
||||
|
||||
// Muss vor der /:id-Route stehen, damit "reorder" nicht als ID interpretiert wird.
|
||||
app.patch("/api/services/reorder", async (request, reply) => {
|
||||
const parsed = ServiceReorderSchema.safeParse(request.body);
|
||||
|
||||
26
apps/backend/src/routes/settings.ts
Normal file
26
apps/backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as settingsRepo from "../db/repositories/settings.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/settings", async () => {
|
||||
const all = settingsRepo.listSettings();
|
||||
return {
|
||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||
};
|
||||
});
|
||||
|
||||
app.patch("/api/settings", async (request, reply) => {
|
||||
const body = request.body as { recentVisitsLimit?: number } | undefined;
|
||||
|
||||
if (body?.recentVisitsLimit !== undefined) {
|
||||
const value = Number(body.recentVisitsLimit);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 50) {
|
||||
return reply.code(400).send({ error: "recentVisitsLimit muss zwischen 0 und 50 liegen" });
|
||||
}
|
||||
settingsRepo.setSetting("recentVisitsLimit", String(Math.round(value)));
|
||||
}
|
||||
|
||||
const all = settingsRepo.listSettings();
|
||||
return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5) };
|
||||
});
|
||||
}
|
||||
250
apps/backend/src/routes/transfer.ts
Normal file
250
apps/backend/src/routes/transfer.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import * as XLSX from "xlsx";
|
||||
import * as deviceRepo from "../db/repositories/devices.js";
|
||||
import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as categoryRepo from "../db/repositories/categories.js";
|
||||
|
||||
interface TransferRow {
|
||||
type: "device" | "service";
|
||||
displayName: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
mac: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
category: string;
|
||||
alias: string;
|
||||
favorite: string;
|
||||
visible: string;
|
||||
order: number | string;
|
||||
port: string | number;
|
||||
https: string;
|
||||
url: string;
|
||||
deviceHostname: string;
|
||||
deviceIp: string;
|
||||
}
|
||||
|
||||
function buildExportRows(): TransferRow[] {
|
||||
const devices = deviceRepo.listDevices();
|
||||
const services = serviceRepo.listServices();
|
||||
const deviceById = new Map(devices.map((d) => [d.id, d]));
|
||||
|
||||
const deviceRows: TransferRow[] = devices.map((d) => ({
|
||||
type: "device",
|
||||
displayName: d.hostname,
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
mac: d.mac ?? "",
|
||||
manufacturer: d.manufacturer ?? "",
|
||||
model: d.model ?? "",
|
||||
category: "",
|
||||
alias: "",
|
||||
favorite: "",
|
||||
visible: "",
|
||||
order: "",
|
||||
port: "",
|
||||
https: "",
|
||||
url: "",
|
||||
deviceHostname: "",
|
||||
deviceIp: "",
|
||||
}));
|
||||
|
||||
const serviceRows: TransferRow[] = services.map((s) => {
|
||||
const device = deviceById.get(s.deviceId);
|
||||
return {
|
||||
type: "service",
|
||||
displayName: s.displayName,
|
||||
hostname: s.hostname,
|
||||
ip: "",
|
||||
mac: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
category: s.category ?? "",
|
||||
alias: s.alias.join(";"),
|
||||
favorite: s.favorite ? "true" : "false",
|
||||
visible: s.visible ? "true" : "false",
|
||||
order: s.order,
|
||||
port: s.port,
|
||||
https: s.https ? "true" : "false",
|
||||
url: s.url,
|
||||
deviceHostname: device?.hostname ?? "",
|
||||
deviceIp: device?.ip ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return [...deviceRows, ...serviceRows];
|
||||
}
|
||||
|
||||
export async function transferRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/transfer/export", async (request, reply) => {
|
||||
const query = request.query as { format?: string };
|
||||
const format = query.format === "xlsx" ? "xlsx" : query.format === "json" ? "json" : "csv";
|
||||
const rows = buildExportRows();
|
||||
|
||||
if (format === "json") {
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.json"');
|
||||
reply.type("application/json");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const worksheet = XLSX.utils.json_to_sheet(rows);
|
||||
|
||||
if (format === "xlsx") {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "LaunchPad");
|
||||
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.xlsx"');
|
||||
reply.type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
return reply.send(buffer);
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
reply.header("Content-Disposition", 'attachment; filename="launchpad-export.csv"');
|
||||
reply.type("text/csv; charset=utf-8");
|
||||
return reply.send(csv);
|
||||
});
|
||||
|
||||
app.post("/api/transfer/import", async (request, reply) => {
|
||||
const body = request.body as { format?: string; content?: string } | undefined;
|
||||
if (!body?.content) {
|
||||
return reply.code(400).send({ error: "Kein Dateiinhalt übermittelt" });
|
||||
}
|
||||
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
if (body.format === "json") {
|
||||
const text = Buffer.from(body.content, "base64").toString("utf-8");
|
||||
rows = JSON.parse(text);
|
||||
} else {
|
||||
const buffer = Buffer.from(body.content, "base64");
|
||||
const workbook = XLSX.read(buffer, { type: "buffer" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.code(400).send({
|
||||
error: "Datei konnte nicht gelesen werden",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
let devicesImported = 0;
|
||||
let devicesSkipped = 0;
|
||||
let servicesImported = 0;
|
||||
let servicesSkipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Geräte-Zeilen zuerst verarbeiten, damit Dienst-Zeilen aus derselben
|
||||
// Datei sie bereits über deviceHostname/deviceIp finden können.
|
||||
const deviceRows = rows.filter((r) => String(r.type ?? "").trim() === "device");
|
||||
const serviceRows = rows.filter((r) => String(r.type ?? "").trim() === "service");
|
||||
|
||||
for (const row of deviceRows) {
|
||||
try {
|
||||
const hostname = String(row.hostname ?? row.displayName ?? "").trim();
|
||||
const ip = String(row.ip ?? "").trim();
|
||||
if (!hostname || !ip) {
|
||||
errors.push(`Geräte-Zeile übersprungen (hostname/ip fehlt): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
const existing =
|
||||
allDevices.find((d) => d.ip === ip) ?? allDevices.find((d) => d.hostname === hostname);
|
||||
|
||||
if (existing) {
|
||||
devicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
deviceRepo.createDevice({
|
||||
hostname,
|
||||
ip,
|
||||
mac: row.mac ? String(row.mac).trim() : undefined,
|
||||
manufacturer: row.manufacturer ? String(row.manufacturer).trim() : undefined,
|
||||
model: row.model ? String(row.model).trim() : undefined,
|
||||
});
|
||||
devicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of serviceRows) {
|
||||
try {
|
||||
const deviceHostname = String(row.deviceHostname ?? "").trim();
|
||||
const deviceIp = String(row.deviceIp ?? "").trim();
|
||||
const hostname = String(row.hostname ?? deviceHostname).trim();
|
||||
const port = Number(row.port);
|
||||
const displayName = String(row.displayName ?? "").trim();
|
||||
const url = String(row.url ?? "").trim();
|
||||
|
||||
if (!displayName || !hostname || !port || !url) {
|
||||
errors.push(`Dienst-Zeile übersprungen (Pflichtfelder fehlen): ${JSON.stringify(row)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const allDevices = deviceRepo.listDevices();
|
||||
let device = deviceIp ? allDevices.find((d) => d.ip === deviceIp) : undefined;
|
||||
if (!device && deviceHostname) {
|
||||
device = allDevices.find((d) => d.hostname === deviceHostname);
|
||||
}
|
||||
if (!device) {
|
||||
device = deviceRepo.createDevice({
|
||||
hostname: deviceHostname || hostname,
|
||||
ip: deviceIp || hostname,
|
||||
});
|
||||
devicesImported++;
|
||||
}
|
||||
|
||||
const existingService = serviceRepo
|
||||
.listServicesByDevice(device.id)
|
||||
.find((s) => s.port === port);
|
||||
if (existingService) {
|
||||
servicesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = row.category ? String(row.category).trim() : undefined;
|
||||
|
||||
serviceRepo.createService({
|
||||
deviceId: device.id,
|
||||
displayName,
|
||||
hostname,
|
||||
url,
|
||||
https: String(row.https ?? "").toLowerCase() === "true",
|
||||
port,
|
||||
category: category || undefined,
|
||||
alias: row.alias
|
||||
? String(row.alias)
|
||||
.split(";")
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
favorite: String(row.favorite ?? "").toLowerCase() === "true",
|
||||
order: row.order !== undefined && row.order !== "" ? Number(row.order) : undefined,
|
||||
visible:
|
||||
row.visible !== undefined && row.visible !== ""
|
||||
? String(row.visible).toLowerCase() === "true"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (category) {
|
||||
categoryRepo.ensureCategory(category);
|
||||
}
|
||||
|
||||
servicesImported++;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
devicesImported,
|
||||
devicesSkipped,
|
||||
servicesImported,
|
||||
servicesSkipped,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user