feat(plugins): csv and json import
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
06a59e1b5c
commit
a540c7d34d
@@ -0,0 +1,139 @@
|
||||
import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
|
||||
import type { MediaType, PartialDate, Status } from "@sttracker/plugin-sdk/types";
|
||||
|
||||
export const FIELDS = [
|
||||
"title", "mediaType", "status", "rating", "ratingScale", "review", "finishedAt", "startedAt",
|
||||
"tags", "isbn13", "imdb", "tmdb_movie", "igdb", "notes", "author",
|
||||
] as const;
|
||||
export type Field = (typeof FIELDS)[number];
|
||||
export type Mapping = Record<string, Field>;
|
||||
/** A row keyed by target field name. */
|
||||
export type FieldRow = Partial<Record<Field, string | string[]>>;
|
||||
|
||||
const ALIASES: Record<Field, string[]> = {
|
||||
title: ["title", "name"],
|
||||
mediaType: ["mediatype", "media type", "type", "media"],
|
||||
status: ["status", "exclusive shelf", "shelf", "state"],
|
||||
rating: ["rating", "my rating", "score", "stars"],
|
||||
ratingScale: ["ratingscale", "rating scale"],
|
||||
review: ["review", "my review"],
|
||||
finishedAt: ["finishedat", "finished", "date read", "date finished", "finished at", "date watched", "watched"],
|
||||
startedAt: ["startedat", "started", "date started", "started at"],
|
||||
tags: ["tags", "bookshelves", "labels", "genres"],
|
||||
isbn13: ["isbn13", "isbn 13"],
|
||||
imdb: ["imdb", "imdb id", "imdb_id"],
|
||||
tmdb_movie: ["tmdb_movie", "tmdb", "tmdb id"],
|
||||
igdb: ["igdb", "igdb id"],
|
||||
notes: ["notes", "private notes", "comments"],
|
||||
author: ["author", "authors", "creator"],
|
||||
};
|
||||
|
||||
export function parseMapping(json: unknown): Mapping | null {
|
||||
if (typeof json !== "string" || json.trim() === "") return null;
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error("csv plugin: mapping is not valid JSON");
|
||||
}
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("csv plugin: mapping must be an object of { column: field }");
|
||||
const out: Mapping = {};
|
||||
for (const [col, f] of Object.entries(v)) {
|
||||
if (!(FIELDS as readonly unknown[]).includes(f)) throw new Error(`csv plugin: unknown mapping field "${String(f)}" for column "${col}"`);
|
||||
out[col] = f as Field;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Guess a mapping from header names, case-insensitively (covers Goodreads exports). */
|
||||
export function guessMapping(headers: string[]): Mapping {
|
||||
const out: Mapping = {};
|
||||
const used = new Set<Field>();
|
||||
for (const h of headers) {
|
||||
const n = h.trim().toLowerCase();
|
||||
const f = FIELDS.find((x) => !used.has(x) && ALIASES[x].includes(n));
|
||||
if (f) {
|
||||
out[h] = f;
|
||||
used.add(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rowsToFieldRows(rows: string[][], mapping: Mapping | null): FieldRow[] {
|
||||
const [header, ...body] = rows;
|
||||
if (!header) return [];
|
||||
const m = mapping ?? guessMapping(header);
|
||||
const idx = header.map((h) => m[h] ?? null);
|
||||
return body.map((cells) => {
|
||||
const r: FieldRow = {};
|
||||
idx.forEach((f, i) => {
|
||||
const v = cells[i];
|
||||
if (f && v !== undefined && v.trim() !== "") r[f] = v.trim();
|
||||
});
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
const MEDIA: MediaType[] = [
|
||||
"book", "comic", "manga", "game", "movie", "show", "season", "episode", "podcast", "podcast_episode",
|
||||
"audio_drama", "artist", "album", "track", "video", "other",
|
||||
];
|
||||
export function parseMediaType(v: string | undefined, fallback: MediaType): MediaType {
|
||||
if (!v) return fallback;
|
||||
const n = v.trim().toLowerCase().replaceAll(/[\s-]+/g, "_");
|
||||
return (MEDIA as string[]).includes(n) ? (n as MediaType) : fallback;
|
||||
}
|
||||
|
||||
const STATUS: Record<string, Status> = {
|
||||
planned: "planned", "to-read": "planned", "to read": "planned", "want to read": "planned", "want to watch": "planned", "plan to watch": "planned", wishlist: "planned", backlog: "planned",
|
||||
in_progress: "in_progress", "in progress": "in_progress", "currently-reading": "in_progress", "currently reading": "in_progress", reading: "in_progress", watching: "in_progress", playing: "in_progress",
|
||||
paused: "paused", "on hold": "paused", "on-hold": "paused",
|
||||
completed: "completed", read: "completed", finished: "completed", watched: "completed", played: "completed", done: "completed", complete: "completed",
|
||||
dropped: "dropped", dnf: "dropped", abandoned: "dropped",
|
||||
none: "none",
|
||||
};
|
||||
export function parseStatus(v: string | undefined): Status | undefined {
|
||||
if (!v) return undefined;
|
||||
return STATUS[v.trim().toLowerCase()] ?? "none";
|
||||
}
|
||||
|
||||
/** Goodreads writes `YYYY/MM/DD`; normalise to dashes. */
|
||||
export function parseDate(v: string | undefined): PartialDate | null {
|
||||
if (!v) return null;
|
||||
const s = v.trim();
|
||||
return parsePartialDate(/^\d{4}\/\d{2}\/\d{2}$/.test(s) ? s.replaceAll("/", "-") : s);
|
||||
}
|
||||
|
||||
/** Per-file default scale: 5 if max<=5, 10 if max<=10, else 100. */
|
||||
export function defaultScale(rows: FieldRow[]): 5 | 10 | 100 {
|
||||
let max = 0;
|
||||
for (const r of rows) {
|
||||
const n = Number(r.rating);
|
||||
if (r.rating !== undefined && Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
return max <= 5 ? 5 : max <= 10 ? 10 : 100;
|
||||
}
|
||||
|
||||
/** Returns a 0-100 rating, or null when unrated. 0 counts as unrated (Goodreads convention). */
|
||||
export function normalizeRating(r: FieldRow, fileScale: 5 | 10 | 100): number | null {
|
||||
if (r.rating === undefined) return null;
|
||||
const n = Number(r.rating);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const rs = Number(r.ratingScale);
|
||||
const scale = rs === 5 || rs === 10 || rs === 100 ? rs : fileScale;
|
||||
return Math.max(0, Math.min(100, Math.round((n / scale) * 100)));
|
||||
}
|
||||
|
||||
/** Goodreads wraps ids as `="978..."`. */
|
||||
export function cleanId(v: string | string[] | undefined): string | undefined {
|
||||
if (typeof v !== "string") return undefined;
|
||||
const s = v.trim().replace(/^=?"?/, "").replace(/"?$/, "").trim();
|
||||
return s === "" ? undefined : s;
|
||||
}
|
||||
|
||||
export function parseTags(v: string | string[] | undefined): string[] | undefined {
|
||||
const list = Array.isArray(v) ? v : typeof v === "string" ? v.split(/[,;]/) : [];
|
||||
const t = list.map((x) => String(x).trim()).filter(Boolean);
|
||||
return t.length ? t : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user