summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/routes.ts1
-rw-r--r--app/routes/api-band-import.tsx173
2 files changed, 174 insertions, 0 deletions
diff --git a/app/routes.ts b/app/routes.ts
index 5660edb..4633169 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -7,6 +7,7 @@ export default [
route("/api/artists", "routes/api-artists.tsx"),
route("/api/export", "routes/api-export.tsx"),
route("/api/import", "routes/api-import.tsx"),
+ route("/api/band-import", "routes/api-band-import.tsx"),
route("/bands", "routes/band-index.tsx"),
route("/bands/new", "routes/band-new.tsx"),
route("/bands/auto-register", "routes/band-auto-register.tsx"),
diff --git a/app/routes/api-band-import.tsx b/app/routes/api-band-import.tsx
new file mode 100644
index 0000000..6c0bc9f
--- /dev/null
+++ b/app/routes/api-band-import.tsx
@@ -0,0 +1,173 @@
+import type { ActionFunctionArgs } from "react-router";
+import {
+ createArtist,
+ createBand,
+ getArtistBySlug,
+ getBandBySlug,
+ getIpAddress,
+ toSlug,
+ type MemberInput,
+} from "~/lib/db.server";
+
+interface LinkInput {
+ label: string;
+ url: string;
+}
+
+interface ArtistImport {
+ name: string;
+ links?: LinkInput[];
+}
+
+interface MemberImport {
+ name: string;
+ role?: string | null;
+ since?: string;
+ until?: string;
+ note?: string;
+}
+
+interface BandImport {
+ name: string;
+ slug?: string;
+ area?: string | null;
+ description?: string | null;
+ status?: string;
+ links?: LinkInput[];
+ members?: MemberImport[];
+ message?: string;
+}
+
+interface ImportPayload {
+ artists?: ArtistImport[];
+ bands: BandImport[];
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method !== "POST") {
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ let payload: ImportPayload;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ if (!payload || !Array.isArray(payload.bands) || payload.bands.length === 0) {
+ return Response.json({ error: "`bands` must be a non-empty array" }, { status: 400 });
+ }
+
+ const ip = getIpAddress(request);
+ const artistIdByName = new Map<string, string>();
+ const artistsCreated: { id: string; name: string }[] = [];
+ const artistsReused: { id: string; name: string }[] = [];
+ const bandsCreated: { id: string; name: string; slug: string }[] = [];
+ const bandsSkipped: { name: string; reason: string }[] = [];
+ const errors: string[] = [];
+
+ // resolves (creating if necessary) an artist by name; reuses an existing
+ // artist when its derived slug already exists so re-running an import is idempotent
+ function resolveArtist(name: string, links: LinkInput[] = []): string | null {
+ const cached = artistIdByName.get(name);
+ if (cached) return cached;
+
+ const baseSlug = toSlug(name);
+ const existing = baseSlug ? getArtistBySlug(baseSlug) : null;
+ if (existing) {
+ artistIdByName.set(name, existing.id);
+ artistsReused.push({ id: existing.id, name: existing.name });
+ return existing.id;
+ }
+
+ const fallbackBase = baseSlug || crypto.randomUUID().slice(0, 8);
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const id = crypto.randomUUID();
+ const slug = attempt === 0 ? fallbackBase : `${fallbackBase}-${id.slice(0, 6)}`;
+ try {
+ createArtist({ id, slug, name, links, message: "JSONインポートで自動作成", ip_address: ip });
+ artistIdByName.set(name, id);
+ artistsCreated.push({ id, name });
+ return id;
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue;
+ errors.push(`アーティスト「${name}」の作成に失敗: ${e instanceof Error ? e.message : "不明なエラー"}`);
+ return null;
+ }
+ }
+ errors.push(`アーティスト「${name}」の作成に失敗: slugの衝突が解消しませんでした`);
+ return null;
+ }
+
+ for (const a of payload.artists ?? []) {
+ if (a?.name) resolveArtist(a.name, a.links ?? []);
+ }
+
+ for (const b of payload.bands) {
+ if (!b?.name) {
+ errors.push("バンド名が空のエントリをスキップしました");
+ continue;
+ }
+
+ const baseSlug = b.slug ? toSlug(b.slug) : toSlug(b.name);
+ if (baseSlug && getBandBySlug(baseSlug)) {
+ bandsSkipped.push({ name: b.name, reason: `slug "${baseSlug}" は既に使用されています` });
+ continue;
+ }
+
+ const members: MemberInput[] = [];
+ for (const m of b.members ?? []) {
+ if (!m?.name) continue;
+ const artistId = resolveArtist(m.name);
+ if (!artistId) continue;
+ members.push({
+ artist_id: artistId,
+ role: m.role ?? null,
+ since: m.since ?? "",
+ until: m.until ?? "",
+ note: m.note ?? "",
+ });
+ }
+
+ const fallbackBase = baseSlug || crypto.randomUUID().slice(0, 8);
+ let stop = false;
+ for (let attempt = 0; attempt < 3 && !stop; attempt++) {
+ const id = crypto.randomUUID();
+ const slug = attempt === 0 ? fallbackBase : `${fallbackBase}-${id.slice(0, 6)}`;
+ try {
+ createBand({
+ id,
+ slug,
+ name: b.name,
+ area: b.area ?? null,
+ description: b.description ?? null,
+ status: b.status ?? "active",
+ links: b.links ?? [],
+ members,
+ message: b.message ?? "JSONインポートで自動登録",
+ ip_address: ip,
+ });
+ bandsCreated.push({ id, name: b.name, slug });
+ stop = true;
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue;
+ errors.push(`バンド「${b.name}」の作成に失敗: ${e instanceof Error ? e.message : "不明なエラー"}`);
+ stop = true;
+ }
+ }
+ }
+
+ return Response.json({
+ ok: errors.length === 0,
+ artistsCreated,
+ artistsReused,
+ bandsCreated,
+ bandsSkipped,
+ errors,
+ });
+}
+
+export default function () {
+ return null;
+}