115 lines
4.7 KiB
TypeScript
115 lines
4.7 KiB
TypeScript
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<string, string | string[]> = {};
|
|
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<SourceRecord> {
|
|
const scale = defaultScale(rows);
|
|
const seen = new Map<string, number>();
|
|
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<string, string> = {};
|
|
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)`);
|
|
}
|
|
},
|
|
});
|