import { createHash } from "node:crypto"; import { parseCsv } from "@sttracker/plugin-sdk/csv"; import { definePlugin } from "@sttracker/plugin-sdk/types"; import type { EngagementFields, MediaType, EntryFields, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types"; import { cleanId, defaultScale, guessMapping, normalizeRating, parseDate, parseMapping, parseMediaType, parseStatus, parseTags, rowsToFieldRows, type FieldRow, } from "./src/mapping.ts"; const str = (v: string | string[] | undefined): string | undefined => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined); function jsonRows(text: string): FieldRow[] { let v: unknown; try { v = JSON.parse(text); } catch { throw new Error("csv plugin: file is not valid JSON"); } if (!Array.isArray(v)) throw new Error("csv plugin: JSON must be an array of objects"); return v.map((o) => { const r: Record = {}; if (typeof o === "object" && o !== null) { for (const [k, x] of Object.entries(o)) { if (x == null || x === "") continue; r[k] = Array.isArray(x) ? x.map(String) : String(x); } } return r as FieldRow; }); } function* toRecords(rows: FieldRow[], rawRows: unknown[], defaultMedia: MediaType): Generator { const scale = defaultScale(rows); const seen = new Map(); for (const [i, r] of rows.entries()) { const title = str(r.title); if (!title) continue; const mediaType = parseMediaType(str(r.mediaType), defaultMedia); const finished = parseDate(str(r.finishedAt)); const started = parseDate(str(r.startedAt)); const hash = createHash("sha1").update([title, mediaType, finished?.value ?? ""].join("\u0000")).digest("hex"); const n = (seen.get(hash) ?? 0) + 1; seen.set(hash, n); const key = n === 1 ? `row:${hash}` : `row:${hash}#${n}`; const externalIds: Record = {}; for (const f of ["isbn13", "imdb", "tmdb_movie", "igdb"] as const) { const id = cleanId(r[f]); if (id) externalIds[f] = id; } const author = str(r.author); const work: WorkHint = { title, mediaType, externalIds, ...(author ? { creators: [{ name: author }] } : {}) }; const entry: EntryFields = {}; const status = parseStatus(str(r.status)); if (status) entry.status = status; const rating = normalizeRating(r, scale); if (rating !== null) entry.rating = rating; const review = str(r.review); if (review) entry.review = review; const notes = str(r.notes); if (notes) entry.notes = notes; const tags = parseTags(r.tags); if (tags) entry.tags = tags; const raw = rawRows[i]; yield { kind: "entry", key, raw, work, entry }; if (finished || started) { const engagement: EngagementFields = {}; if (started) engagement.startedAt = started; if (finished) engagement.finishedAt = finished; yield { kind: "engagement", key: `${key}:engagement`, raw, work, engagement }; } } } export default definePlugin({ manifest: { id: "csv", name: "CSV / JSON import", version: "0.1.0", sdkVersion: 1, mediaTypes: ["book", "comic", "manga", "game", "movie", "show", "podcast", "audio_drama", "album", "video", "other"], capabilities: ["importFile"], config: [ { key: "mapping", label: "Column mapping", type: "string", help: 'JSON { "column": "field" }. Fields: title mediaType status rating ratingScale(5|10|100) review finishedAt startedAt tags isbn13 imdb tmdb_movie igdb notes. Leave empty to guess from header names (Goodreads exports work out of the box). Ignored for .json.', }, ], }, async *importFile(ctx, file) { const text = new TextDecoder("utf-8").decode(file.bytes); const lower = file.name.toLowerCase(); if (lower.endsWith(".json")) { const clean = text.replace(/^\uFEFF/, ""); const rows = jsonRows(clean); const raw = JSON.parse(clean) as unknown[]; const bookish = raw.some((o) => typeof o === "object" && o !== null && ("author" in o || "isbn13" in o)); yield* toRecords(rows, raw, bookish ? "book" : "other"); } else if (lower.endsWith(".csv")) { const table = parseCsv(text); const header = table[0] ?? []; const mapping = parseMapping(ctx.config.mapping) ?? guessMapping(header); const rows = rowsToFieldRows(table, mapping); const bookish = header.some((h) => mapping[h] === "author" || mapping[h] === "isbn13"); yield* toRecords(rows, table.slice(1).map((cells) => Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ""]))), bookish ? "book" : "other"); } else { throw new Error(`csv plugin: unsupported file "${file.name}" (expected .csv or .json)`); } }, });