diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 22:28:23 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 22:28:23 +0900 |
| commit | 11f6a452ee2d4796ad9786b2ad3ce6c9ad171262 (patch) | |
| tree | 21203ae21e0502b8f8e1652115539e74ae1607d7 | |
| parent | b9e06d9a139e6d9a6d509f500beee97d0d071602 (diff) | |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app/routes.ts | 1 | ||||
| -rw-r--r-- | app/routes/api-band-import.tsx | 173 |
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; +} |
