diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-09 11:21:28 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-09 11:21:28 +0900 |
| commit | cd8787b77dadf752826a967d404b718b3ec92601 (patch) | |
| tree | a1e5471ba59404caf5c3c7684a4cfc08027a5a4b | |
| parent | 08c410c28eeb3d7a4c41014d8926b765441546c4 (diff) | |
Add JSON API endpoints and CLI script for band/artist management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 74 | ||||
| -rw-r--r-- | app/routes.ts | 2 | ||||
| -rw-r--r-- | app/routes/api-artists.tsx | 47 | ||||
| -rw-r--r-- | app/routes/api-bands.tsx | 51 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | scripts/add.ts | 159 |
6 files changed, 335 insertions, 1 deletions
@@ -25,6 +25,80 @@ | `/artists/of/:uuid/history` | アーティスト編集履歴 | | `/artists/new` | アーティスト新規作成 | +## API + +サーバー起動中に JSON API で操作できます。 + +### バンド + +```bash +# 一覧取得 +curl http://localhost:5173/api/bands + +# 新規作成 +curl -X POST http://localhost:5173/api/bands \ + -H "Content-Type: application/json" \ + -d '{ + "name": "バンド名", + "area": "Tokyo", + "status": "active", + "description": "説明文", + "links": [{"label": "Twitter", "url": "https://..."}], + "artists": [{"id": "<artist-uuid>", "role": "Vocal"}], + "message": "初回登録" + }' +``` + +### アーティスト + +```bash +# 一覧取得 +curl http://localhost:5173/api/artists + +# 新規作成 +curl -X POST http://localhost:5173/api/artists \ + -H "Content-Type: application/json" \ + -d '{"name": "アーティスト名", "links": [{"label": "Twitter", "url": "https://..."}]}' +``` + +`slug` は省略すると名前から自動生成。成功時は `201` + 作成したレコードの JSON を返します。slug 重複時は `409`。 + +## CLI + +サーバー不要でローカルの DB に直接書き込みます。プロジェクトルートから実行してください。 + +```bash +# アーティスト登録 +npm run add -- artist --name "アーティスト名" + +# バンド登録 +npm run add -- band --name "バンド名" --area Tokyo --status active + +# JSON ファイルから一括インポート +npm run add -- import --file data.json +``` + +### 一括インポートの JSON 形式 + +```json +{ + "artists": [ + {"name": "アーティスト A", "links": [{"label": "Twitter", "url": "https://..."}]} + ], + "bands": [ + { + "name": "バンド X", + "area": "Tokyo", + "status": "active", + "artists": [{"name": "アーティスト A", "role": "Vocal"}], + "links": [{"label": "Twitter", "url": "https://..."}] + } + ] +} +``` + +`artists` を先に処理するので、`bands` 側でアーティスト名を参照できます。既存のアーティスト(名前一致)はスキップ。 + ## 技術スタック - [React Router v7](https://reactrouter.com/) (SSR フレームワーク) diff --git a/app/routes.ts b/app/routes.ts index a4e737f..d02abf7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -2,6 +2,8 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), + route("/api/bands", "routes/api-bands.tsx"), + route("/api/artists", "routes/api-artists.tsx"), route("/bands/new", "routes/band-new.tsx"), route("/bands/of/:uuid", "routes/band-by-uuid.tsx"), route("/bands/named/:slug", "routes/band-by-slug.tsx"), diff --git a/app/routes/api-artists.tsx b/app/routes/api-artists.tsx new file mode 100644 index 0000000..ed762bf --- /dev/null +++ b/app/routes/api-artists.tsx @@ -0,0 +1,47 @@ +import type { ActionFunctionArgs } from "react-router"; +import { createArtist, getIpAddress, listArtists, toSlug } from "~/lib/db.server"; + +export function loader() { + return Response.json(listArtists()); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + let body: Record<string, unknown>; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const name = (body.name as string | undefined)?.trim(); + if (!name) return Response.json({ error: "name is required" }, { status: 400 }); + + const slug = (body.slug as string | undefined)?.trim() || toSlug(name); + if (!slug) return Response.json({ error: "could not derive slug from name" }, { status: 400 }); + + const id = crypto.randomUUID(); + try { + const artist = createArtist({ + id, + slug, + name, + links: (body.links as { label: string; url: string }[]) || [], + message: (body.message as string) || "API import", + ip_address: getIpAddress(request), + }); + return Response.json(artist, { status: 201 }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: artists.slug")) { + return Response.json({ error: "slug already in use" }, { status: 409 }); + } + throw e; + } +} + +export default function () { + return null; +} diff --git a/app/routes/api-bands.tsx b/app/routes/api-bands.tsx new file mode 100644 index 0000000..64a9269 --- /dev/null +++ b/app/routes/api-bands.tsx @@ -0,0 +1,51 @@ +import type { ActionFunctionArgs } from "react-router"; +import { createBand, getIpAddress, listBands, toSlug } from "~/lib/db.server"; + +export function loader() { + return Response.json(listBands()); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return Response.json({ error: "Method not allowed" }, { status: 405 }); + } + + let body: Record<string, unknown>; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const name = (body.name as string | undefined)?.trim(); + if (!name) return Response.json({ error: "name is required" }, { status: 400 }); + + const slug = (body.slug as string | undefined)?.trim() || toSlug(name); + if (!slug) return Response.json({ error: "could not derive slug from name" }, { status: 400 }); + + const id = crypto.randomUUID(); + try { + const band = createBand({ + id, + slug, + name, + area: (body.area as string) || null, + description: (body.description as string) || null, + status: (body.status as string) || "active", + links: (body.links as { label: string; url: string }[]) || [], + artists: (body.artists as { id: string; role: string | null }[]) || [], + message: (body.message as string) || "API import", + ip_address: getIpAddress(request), + }); + return Response.json(band, { status: 201 }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) { + return Response.json({ error: "slug already in use" }, { status: 409 }); + } + throw e; + } +} + +export default function () { + return null; +} diff --git a/package.json b/package.json index e153c04..fefa788 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build": "react-router build", "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", - "typecheck": "react-router typegen && tsc" + "typecheck": "react-router typegen && tsc", + "add": "tsx scripts/add.ts" }, "dependencies": { "@react-router/node": "^7.3.0", diff --git a/scripts/add.ts b/scripts/add.ts new file mode 100644 index 0000000..ca53dda --- /dev/null +++ b/scripts/add.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env npx tsx +/** + * CLI for adding bands/artists directly to the DB (no server required). + * + * Usage: + * npx tsx scripts/add.ts band --name "Name" [--slug x] [--area X] [--status active|hiatus|disbanded] [--description X] [--message X] + * npx tsx scripts/add.ts artist --name "Name" [--slug x] [--message X] + * npx tsx scripts/add.ts import --file data.json + * + * JSON import format (data.json): + * { + * "artists": [{ "name": "...", "links": [{ "label": "Twitter", "url": "..." }] }], + * "bands": [{ + * "name": "...", "area": "...", "status": "active", + * "artists": [{ "name": "...", "role": "Vocal" }], + * "links": [{ "label": "Twitter", "url": "..." }] + * }] + * } + * + * Artist references in bands can use { "id": "<uuid>" } or { "name": "..." } (looked up by name). + * Artists listed under "artists" are created first so band references by name work. + * Run from the project root so whois.db is found automatically. + */ + +import { readFileSync } from "fs"; +import { createArtist, createBand, listArtists, toSlug } from "../app/lib/db.server"; + +type LinkInput = { label: string; url: string }; +type ArtistRef = { id?: string; name?: string; role?: string | null }; + +function parseFlags(argv: string[]): Record<string, string> { + const flags: Record<string, string> = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith("--")) { + const key = argv[i].slice(2); + flags[key] = argv[i + 1] ?? "true"; + i++; + } + } + return flags; +} + +const [, , command, ...rest] = process.argv; + +if (!command || command === "--help" || command === "-h") { + console.log(`Usage: + npx tsx scripts/add.ts band --name "Name" [--slug x] [--area X] [--status active|hiatus|disbanded] [--description X] [--message X] + npx tsx scripts/add.ts artist --name "Name" [--slug x] [--message X] + npx tsx scripts/add.ts import --file data.json`); + process.exit(0); +} + +if (command === "artist") { + const flags = parseFlags(rest); + const name = flags.name?.trim(); + if (!name) { console.error("Error: --name is required"); process.exit(1); } + const slug = flags.slug?.trim() || toSlug(name); + const id = crypto.randomUUID(); + const artist = createArtist({ + id, slug, name, + links: flags.links ? (JSON.parse(flags.links) as LinkInput[]) : [], + message: flags.message || "CLI import", + ip_address: "cli", + }); + console.log(`Created artist: ${artist.name}`); + console.log(` id: ${artist.id}`); + console.log(` url: /artists/of/${artist.id}`); + +} else if (command === "band") { + const flags = parseFlags(rest); + const name = flags.name?.trim(); + if (!name) { console.error("Error: --name is required"); process.exit(1); } + const slug = flags.slug?.trim() || toSlug(name); + const id = crypto.randomUUID(); + try { + const band = createBand({ + id, slug, name, + area: flags.area || null, + description: flags.description || null, + status: flags.status || "active", + links: flags.links ? (JSON.parse(flags.links) as LinkInput[]) : [], + artists: flags.artists ? (JSON.parse(flags.artists) as { id: string; role: string | null }[]) : [], + message: flags.message || "CLI import", + ip_address: "cli", + }); + console.log(`Created band: ${band.name}`); + console.log(` id: ${band.id}`); + console.log(` url: /bands/of/${band.id}`); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) { + console.error(`Error: slug "${slug}" is already in use`); + process.exit(1); + } + throw e; + } + +} else if (command === "import") { + const flags = parseFlags(rest); + if (!flags.file) { console.error("Error: --file is required"); process.exit(1); } + + const data = JSON.parse(readFileSync(flags.file, "utf-8")) as { + artists?: { name: string; slug?: string; links?: LinkInput[]; message?: string }[]; + bands?: { name: string; slug?: string; area?: string; description?: string; status?: string; links?: LinkInput[]; artists?: ArtistRef[]; message?: string }[]; + }; + + // Build name→id map from existing artists, then create new ones + const nameToId = new Map<string, string>(listArtists().map((a) => [a.name, a.id])); + + for (const a of data.artists ?? []) { + const name = a.name?.trim(); + if (!name) continue; + if (nameToId.has(name)) { console.log(`Skip existing artist: ${name}`); continue; } + const id = crypto.randomUUID(); + createArtist({ + id, slug: a.slug?.trim() || toSlug(name), name, + links: a.links || [], + message: a.message || "JSON import", + ip_address: "cli", + }); + nameToId.set(name, id); + console.log(`Created artist: ${name} (${id})`); + } + + for (const b of data.bands ?? []) { + const name = b.name?.trim(); + if (!name) continue; + const id = crypto.randomUUID(); + const artists = (b.artists ?? []) + .map((ar) => { + const artistId = ar.id ?? (ar.name ? nameToId.get(ar.name) : undefined); + if (!artistId) { console.warn(` Warning: artist not found: "${ar.name}"`); return null; } + return { id: artistId, role: ar.role ?? null }; + }) + .filter((x): x is { id: string; role: string | null } => x !== null); + try { + createBand({ + id, slug: b.slug?.trim() || toSlug(name), name, + area: b.area || null, + description: b.description || null, + status: b.status || "active", + links: b.links || [], + artists, + message: b.message || "JSON import", + ip_address: "cli", + }); + console.log(`Created band: ${name} (${id})`); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) { + console.error(`Skip band "${name}": slug already in use`); + } else { + throw e; + } + } + } + +} else { + console.error(`Unknown command: "${command}". Use band, artist, or import.`); + process.exit(1); +} |
