feat: Import aus Trakt und CSV, Export als CSV und JSON
Grundhaltung beim Import: nichts überschreiben. Was schon in der Bibliothek liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein Import soll niemand versehentlich seinen Fortschritt kosten. Trakt gibt je nach Ansicht unterschiedliche Formen aus. Gemeinsam ist ihnen ein Array mit Einträgen, die entweder show oder movie tragen — genau darauf stützt sich der Import, statt ein bestimmtes Exportformat zu verlangen. Der CSV-Leser errät das Trennzeichen: deutsche Tabellenprogramme nehmen gern das Semikolon.
This commit is contained in:
@@ -0,0 +1,516 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { ensureTitle } from "./catalog";
|
||||||
|
import { addToLibrary } from "./library";
|
||||||
|
import { episodeCode } from "./labels";
|
||||||
|
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daten hinein und hinaus.
|
||||||
|
*
|
||||||
|
* Hinein: Trakt.tv-Export (JSON) und CSV, wie es Serienjunkies, TV Time und
|
||||||
|
* Tabellenkalkulationen ausspucken. Hinaus: dieselben Daten als CSV oder JSON.
|
||||||
|
*
|
||||||
|
* Grundhaltung beim Import: **nichts überschreiben**. Was schon in der
|
||||||
|
* Bibliothek liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein
|
||||||
|
* Import soll niemand versehentlich seinen Fortschritt kosten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImportSummary = {
|
||||||
|
processed: number;
|
||||||
|
added: number;
|
||||||
|
skipped: number;
|
||||||
|
watchesAdded: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ALIASES: Record<string, EntryStatus> = {
|
||||||
|
watching: "WATCHING",
|
||||||
|
"schaue gerade": "WATCHING",
|
||||||
|
laufend: "WATCHING",
|
||||||
|
paused: "PAUSED",
|
||||||
|
pausiert: "PAUSED",
|
||||||
|
hold: "PAUSED",
|
||||||
|
completed: "COMPLETED",
|
||||||
|
abgeschlossen: "COMPLETED",
|
||||||
|
watched: "COMPLETED",
|
||||||
|
gesehen: "COMPLETED",
|
||||||
|
dropped: "DROPPED",
|
||||||
|
abgebrochen: "DROPPED",
|
||||||
|
watchlist: "WATCHLIST",
|
||||||
|
plantowatch: "WATCHLIST",
|
||||||
|
merkliste: "WATCHLIST",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseStatus(value: string | undefined): EntryStatus {
|
||||||
|
if (!value) return "WATCHLIST";
|
||||||
|
return STATUS_ALIASES[value.trim().toLocaleLowerCase("de-DE")] ?? "WATCHLIST";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseType(value: string | undefined): TitleType | null {
|
||||||
|
const normalized = value?.trim().toLocaleLowerCase("de-DE");
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (["serie", "series", "show", "tv", "tvshow"].includes(normalized)) return "SERIES";
|
||||||
|
if (["film", "movie", "kinofilm"].includes(normalized)) return "MOVIE";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSV
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimaler CSV-Leser mit Unterstützung für Anführungszeichen und
|
||||||
|
* eingebettete Zeilenumbrüche. Trennzeichen wird erraten (Komma oder
|
||||||
|
* Semikolon) — deutsche Tabellenprogramme nehmen gern das Semikolon.
|
||||||
|
*/
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const clean = text.replace(/^/, "");
|
||||||
|
const firstLine = clean.split(/\r?\n/, 1)[0] ?? "";
|
||||||
|
const delimiter = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ",";
|
||||||
|
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < clean.length; index += 1) {
|
||||||
|
const char = clean[index];
|
||||||
|
|
||||||
|
if (quoted) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (clean[index + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
quoted = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
quoted = true;
|
||||||
|
} else if (char === delimiter) {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
} else if (char === "\n") {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
} else if (char !== "\r") {
|
||||||
|
field += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.length > 0 || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((entry) => entry.some((cell) => cell.trim().length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSV_HEADERS: Record<string, string> = {
|
||||||
|
titel: "title",
|
||||||
|
title: "title",
|
||||||
|
name: "title",
|
||||||
|
show: "title",
|
||||||
|
typ: "type",
|
||||||
|
type: "type",
|
||||||
|
art: "type",
|
||||||
|
status: "status",
|
||||||
|
bewertung: "rating",
|
||||||
|
rating: "rating",
|
||||||
|
score: "rating",
|
||||||
|
tmdb: "tmdbId",
|
||||||
|
tmdb_id: "tmdbId",
|
||||||
|
tmdbid: "tmdbId",
|
||||||
|
jahr: "year",
|
||||||
|
year: "year",
|
||||||
|
notiz: "notes",
|
||||||
|
notes: "notes",
|
||||||
|
kommentar: "notes",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV-Import.
|
||||||
|
*
|
||||||
|
* Erwartet eine Kopfzeile. Gebraucht wird nur eine Spalte mit dem Titel;
|
||||||
|
* Typ, Status, Bewertung, TMDB-ID und Notiz werden übernommen, wenn vorhanden.
|
||||||
|
*/
|
||||||
|
export async function importCsv(userId: string, text: string): Promise<ImportSummary> {
|
||||||
|
const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] };
|
||||||
|
const rows = parseCsv(text);
|
||||||
|
const header = rows.shift();
|
||||||
|
|
||||||
|
if (!header) {
|
||||||
|
summary.errors.push("Die Datei ist leer.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = header.map(
|
||||||
|
(cell) => CSV_HEADERS[cell.trim().toLocaleLowerCase("de-DE").replace(/\s+/g, "_")] ?? "",
|
||||||
|
);
|
||||||
|
if (!columns.includes("title")) {
|
||||||
|
summary.errors.push(
|
||||||
|
'Es fehlt eine Titelspalte. Die Kopfzeile braucht eine Spalte „Titel" (oder „title", „name", „show").',
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
summary.processed += 1;
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
columns.forEach((key, index) => {
|
||||||
|
if (key) record[key] = (row[index] ?? "").trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = record.title;
|
||||||
|
if (!title) {
|
||||||
|
summary.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importOne(userId, {
|
||||||
|
title,
|
||||||
|
type: parseType(record.type),
|
||||||
|
tmdbId: record.tmdbId ? Number.parseInt(record.tmdbId, 10) : null,
|
||||||
|
status: parseStatus(record.status),
|
||||||
|
rating: record.rating ? Number.parseInt(record.rating, 10) : null,
|
||||||
|
year: record.year ? Number.parseInt(record.year, 10) : null,
|
||||||
|
notes: record.notes || null,
|
||||||
|
});
|
||||||
|
if (result === "added") summary.added += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trakt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TraktEntry = {
|
||||||
|
type?: string;
|
||||||
|
show?: { title?: string; year?: number; ids?: { tmdb?: number } };
|
||||||
|
movie?: { title?: string; year?: number; ids?: { tmdb?: number } };
|
||||||
|
title?: string;
|
||||||
|
year?: number;
|
||||||
|
ids?: { tmdb?: number };
|
||||||
|
rating?: number;
|
||||||
|
listed_at?: string;
|
||||||
|
last_watched_at?: string;
|
||||||
|
watched_at?: string;
|
||||||
|
plays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trakt.tv-Export.
|
||||||
|
*
|
||||||
|
* Trakt gibt je nach Ansicht unterschiedliche Formen aus (Watchlist,
|
||||||
|
* History, Ratings, Collection). Gemeinsam ist ihnen ein Array mit Einträgen,
|
||||||
|
* die entweder `show` oder `movie` tragen — genau darauf stützt sich der
|
||||||
|
* Import, statt ein bestimmtes Exportformat zu verlangen.
|
||||||
|
*/
|
||||||
|
export async function importTrakt(userId: string, json: string): Promise<ImportSummary> {
|
||||||
|
const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] };
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
summary.errors.push("Die Datei ist kein gültiges JSON.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Array direkt, oder ein Objekt mit Listen darin.
|
||||||
|
const entries: TraktEntry[] = Array.isArray(parsed)
|
||||||
|
? (parsed as TraktEntry[])
|
||||||
|
: typeof parsed === "object" && parsed !== null
|
||||||
|
? Object.values(parsed as Record<string, unknown>)
|
||||||
|
.filter(Array.isArray)
|
||||||
|
.flat() as TraktEntry[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
summary.errors.push("In der Datei stehen keine Einträge, die Flimmer versteht.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
summary.processed += 1;
|
||||||
|
|
||||||
|
const source = entry.show ?? entry.movie ?? entry;
|
||||||
|
const title = source.title;
|
||||||
|
if (!title) {
|
||||||
|
summary.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type: TitleType | null = entry.show
|
||||||
|
? "SERIES"
|
||||||
|
: entry.movie
|
||||||
|
? "MOVIE"
|
||||||
|
: parseType(entry.type);
|
||||||
|
|
||||||
|
// Was schon gesehen wurde, gilt als abgeschlossen; alles andere landet
|
||||||
|
// auf der Watchlist. Genauer geht es aus einem Export nicht hervor.
|
||||||
|
const watched = Boolean(entry.watched_at ?? entry.last_watched_at ?? entry.plays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importOne(userId, {
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
tmdbId: source.ids?.tmdb ?? null,
|
||||||
|
status: watched ? "COMPLETED" : "WATCHLIST",
|
||||||
|
rating: typeof entry.rating === "number" ? Math.round(entry.rating) : null,
|
||||||
|
year: source.year ?? null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
if (result === "added") summary.added += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gemeinsamer Kern
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ImportRecord = {
|
||||||
|
title: string;
|
||||||
|
type: TitleType | null;
|
||||||
|
tmdbId: number | null;
|
||||||
|
status: EntryStatus;
|
||||||
|
rating: number | null;
|
||||||
|
year: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen einzelnen Eintrag auf.
|
||||||
|
*
|
||||||
|
* Mit TMDB-ID wird der Titel bei TMDB geholt; ohne wird im lokalen Katalog
|
||||||
|
* nach dem Namen gesucht und andernfalls ein manueller Eintrag angelegt.
|
||||||
|
* Das ist bewusst so: ein Import soll auch ohne TMDB-Schlüssel durchlaufen.
|
||||||
|
*/
|
||||||
|
async function importOne(userId: string, record: ImportRecord): Promise<"added" | "skipped"> {
|
||||||
|
let titleId: string | null = null;
|
||||||
|
|
||||||
|
if (record.tmdbId && record.type && env.tmdb.isConfigured) {
|
||||||
|
try {
|
||||||
|
const title = await ensureTitle(record.type, record.tmdbId, { region: env.tmdb.region });
|
||||||
|
titleId = title.id;
|
||||||
|
} catch {
|
||||||
|
// TMDB nicht erreichbar oder ID unbekannt — unten weiterversuchen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!titleId) {
|
||||||
|
const existing = await prisma.title.findFirst({
|
||||||
|
where: {
|
||||||
|
title: { equals: record.title, mode: "insensitive" },
|
||||||
|
...(record.type ? { type: record.type } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
titleId = existing?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!titleId) {
|
||||||
|
const created = await prisma.title.create({
|
||||||
|
data: {
|
||||||
|
type: record.type ?? "MOVIE",
|
||||||
|
title: record.title,
|
||||||
|
releaseDate: record.year ? new Date(Date.UTC(record.year, 0, 1)) : null,
|
||||||
|
status: "Importiert",
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
titleId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await prisma.libraryEntry.findUnique({
|
||||||
|
where: { userId_titleId: { userId, titleId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingEntry) return "skipped";
|
||||||
|
|
||||||
|
const entry = await addToLibrary(userId, titleId, record.status);
|
||||||
|
|
||||||
|
if (record.rating || record.notes) {
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: {
|
||||||
|
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
|
||||||
|
notes: record.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return "added";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function csvCell(value: unknown): string {
|
||||||
|
const text = value === null || value === undefined ? "" : String(value);
|
||||||
|
return /[",;\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bibliothek als CSV — ein Titel je Zeile. */
|
||||||
|
export async function exportCsv(userId: string): Promise<string> {
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
orderBy: { title: { title: "asc" } },
|
||||||
|
include: {
|
||||||
|
title: true,
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
"Titel",
|
||||||
|
"Typ",
|
||||||
|
"Jahr",
|
||||||
|
"TMDB",
|
||||||
|
"Status",
|
||||||
|
"Bewertung",
|
||||||
|
"Durchlaeufe",
|
||||||
|
"WoGeschaut",
|
||||||
|
"Tags",
|
||||||
|
"Notiz",
|
||||||
|
"Hinzugefuegt",
|
||||||
|
"Abgeschlossen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = entries.map((entry) =>
|
||||||
|
[
|
||||||
|
entry.title.title,
|
||||||
|
entry.title.type === "SERIES" ? "Serie" : "Film",
|
||||||
|
entry.title.releaseDate?.getFullYear() ?? "",
|
||||||
|
entry.title.tmdbId ?? "",
|
||||||
|
entry.status,
|
||||||
|
entry.rating ?? "",
|
||||||
|
entry.currentRun + 1,
|
||||||
|
entry.whereToWatch ?? "",
|
||||||
|
entry.tags.map((link) => link.tag.name).join(" "),
|
||||||
|
entry.notes?.replace(/\r?\n/g, " ") ?? "",
|
||||||
|
entry.createdAt.toISOString().slice(0, 10),
|
||||||
|
entry.finishedAt?.toISOString().slice(0, 10) ?? "",
|
||||||
|
]
|
||||||
|
.map(csvCell)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [header.join(","), ...rows].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollständiger Export als JSON, inklusive Sichtungen und Universen. */
|
||||||
|
export async function exportJson(userId: string): Promise<object> {
|
||||||
|
const [user, entries, watches, universes] = await Promise.all([
|
||||||
|
prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { email: true, name: true, region: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
title: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
tmdbId: true,
|
||||||
|
imdbId: true,
|
||||||
|
releaseDate: true,
|
||||||
|
genres: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.watch.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { watchedAt: "asc" },
|
||||||
|
include: {
|
||||||
|
title: { select: { title: true, tmdbId: true, type: true } },
|
||||||
|
episode: { select: { seasonNumber: true, episodeNumber: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.universe.findMany({
|
||||||
|
where: { ownerId: userId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
items: { include: { title: { select: { title: true, tmdbId: true, type: true } } } },
|
||||||
|
orders: { include: { items: { orderBy: { position: "asc" } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "flimmer-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
user,
|
||||||
|
library: entries.map((entry) => ({
|
||||||
|
title: entry.title.title,
|
||||||
|
type: entry.title.type,
|
||||||
|
tmdbId: entry.title.tmdbId,
|
||||||
|
imdbId: entry.title.imdbId,
|
||||||
|
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||||
|
genres: entry.title.genres,
|
||||||
|
status: entry.status,
|
||||||
|
rating: entry.rating,
|
||||||
|
notes: entry.notes,
|
||||||
|
whereToWatch: entry.whereToWatch,
|
||||||
|
favorite: entry.favorite,
|
||||||
|
currentRun: entry.currentRun,
|
||||||
|
tags: entry.tags.map((link) => link.tag.name),
|
||||||
|
startedAt: entry.startedAt,
|
||||||
|
finishedAt: entry.finishedAt,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
})),
|
||||||
|
watches: watches.map((watch) => ({
|
||||||
|
title: watch.title.title,
|
||||||
|
tmdbId: watch.title.tmdbId,
|
||||||
|
type: watch.title.type,
|
||||||
|
episode: watch.episode
|
||||||
|
? episodeCode(watch.episode.seasonNumber, watch.episode.episodeNumber)
|
||||||
|
: null,
|
||||||
|
episodeName: watch.episode?.name ?? null,
|
||||||
|
run: watch.run,
|
||||||
|
watchedAt: watch.watchedAt,
|
||||||
|
minutes: watch.minutes,
|
||||||
|
})),
|
||||||
|
universes: universes.map((universe) => ({
|
||||||
|
name: universe.name,
|
||||||
|
description: universe.description,
|
||||||
|
titles: universe.items.map((item) => ({
|
||||||
|
title: item.title.title,
|
||||||
|
tmdbId: item.title.tmdbId,
|
||||||
|
type: item.title.type,
|
||||||
|
})),
|
||||||
|
orders: universe.orders.map((order) => ({
|
||||||
|
name: order.name,
|
||||||
|
kind: order.kind,
|
||||||
|
positions: order.items.map((entry) => entry.position),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user