From e9e576abd9d6c6030aa4bb290e869890831488ad Mon Sep 17 00:00:00 2001 From: yyamashita Date: Mon, 11 May 2026 00:06:52 +0900 Subject: Add lists feature (band recommendation lists with history) New lists, list_entries, list_revisions tables; full CRUD routes under /lists; nav link in root. Co-Authored-By: Claude Sonnet 4.6 --- app/app.css | 14 +++++ app/lib/db.server.ts | 150 ++++++++++++++++++++++++++++++++++++++++++++ app/root.tsx | 1 + app/routes.ts | 6 ++ app/routes/list-by-slug.tsx | 13 ++++ app/routes/list-by-uuid.tsx | 54 ++++++++++++++++ app/routes/list-edit.tsx | 137 ++++++++++++++++++++++++++++++++++++++++ app/routes/list-history.tsx | 48 ++++++++++++++ app/routes/list-index.tsx | 36 +++++++++++ app/routes/list-new.tsx | 133 +++++++++++++++++++++++++++++++++++++++ scripts/seed-mosquitone.ts | 137 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 729 insertions(+) create mode 100644 app/routes/list-by-slug.tsx create mode 100644 app/routes/list-by-uuid.tsx create mode 100644 app/routes/list-edit.tsx create mode 100644 app/routes/list-history.tsx create mode 100644 app/routes/list-index.tsx create mode 100644 app/routes/list-new.tsx create mode 100644 scripts/seed-mosquitone.ts diff --git a/app/app.css b/app/app.css index 1129e5a..90dc85f 100644 --- a/app/app.css +++ b/app/app.css @@ -154,3 +154,17 @@ form input, form select, form textarea { width: 100%; } .rev-time { font-size: .75rem; color: #6b7280; margin-top: .25rem; } .rev-latest { font-size: .75rem; color: #60a5fa; flex-shrink: 0; } .rev-snap { font-size: .75rem; color: #9ca3af; margin-top: .75rem; display: flex; flex-direction: column; gap: .125rem; } + + +/* ── List entries (detail view) ── */ + +.entry-list { display: flex; flex-direction: column; gap: .5rem; list-style: none; } +.entry-list li { padding: .375rem 0; border-bottom: 1px solid #1f2937; } +.entry-band { font-weight: 500; color: #e5e7eb; } +.entry-note { font-size: .8rem; color: #6b7280; margin-top: .125rem; } + +/* ── Entry form rows (new/edit) ── */ + +.entry-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .375rem; } +.entry-row .band-input { flex: 0 0 10rem; } +.entry-row .note-input { flex: 1; min-width: 0; } diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index ab32126..c4b11b4 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -86,6 +86,31 @@ function initSchema(db: Database.Database) { ); CREATE INDEX IF NOT EXISTS idx_band_links_band_id ON band_links(band_id); + + CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + slug TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS list_entries ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + band_name TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + order_index INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS list_revisions ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + snapshot TEXT NOT NULL, + message TEXT NOT NULL, + ip_address TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); // migrations @@ -115,6 +140,8 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_members_artist_id ON members(artist_id); CREATE INDEX IF NOT EXISTS idx_band_revisions_band_id ON band_revisions(band_id); CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id); + CREATE INDEX IF NOT EXISTS idx_list_entries_list_id ON list_entries(list_id); + CREATE INDEX IF NOT EXISTS idx_list_revisions_list_id ON list_revisions(list_id); `); } @@ -534,6 +561,129 @@ export function updateArtist(id: string, input: UpdateArtistInput): void { })(); } +// ── List queries ────────────────────────────────────────────────────────────── + +export interface BandList { + id: string; + slug: string; + title: string; + description: string; + created_at: string; +} + +export interface ListEntry { + id: string; + list_id: string; + band_name: string; + note: string; + order_index: number; +} + +export interface ListRevision { + id: string; + list_id: string; + snapshot: string; + message: string; + ip_address: string; + created_at: string; +} + +export interface ListEntryInput { + band_name: string; + note: string; +} + +export interface CreateListInput { + id: string; + slug: string; + title: string; + description: string; + entries: ListEntryInput[]; + message: string; + ip_address: string; +} + +export interface UpdateListInput { + slug: string; + title: string; + description: string; + entries: ListEntryInput[]; + message: string; + ip_address: string; +} + +export function listBandLists(): BandList[] { + return getDb().prepare("SELECT * FROM lists ORDER BY created_at DESC").all() as BandList[]; +} + +export function getBandListById(id: string): BandList | null { + return getDb().prepare("SELECT * FROM lists WHERE id = ?").get(id) as BandList | null; +} + +export function getBandListBySlug(slug: string): BandList | null { + return getDb().prepare("SELECT * FROM lists WHERE slug = ?").get(slug) as BandList | null; +} + +export function getListEntries(listId: string): ListEntry[] { + return getDb() + .prepare("SELECT * FROM list_entries WHERE list_id = ? ORDER BY order_index") + .all(listId) as ListEntry[]; +} + +export function getListRevisions(listId: string): ListRevision[] { + return getDb() + .prepare("SELECT * FROM list_revisions WHERE list_id = ? ORDER BY created_at DESC") + .all(listId) as ListRevision[]; +} + +export function createBandList(input: CreateListInput): BandList { + const db = getDb(); + return db.transaction(() => { + db.prepare("INSERT INTO lists (id, slug, title, description) VALUES (?, ?, ?, ?)").run( + input.id, input.slug, input.title, input.description + ); + input.entries.forEach((e, i) => { + db.prepare( + "INSERT INTO list_entries (id, list_id, band_name, note, order_index) VALUES (?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), input.id, e.band_name, e.note, i); + }); + const list = getBandListById(input.id)!; + const entries = getListEntries(input.id); + db.prepare( + "INSERT INTO list_revisions (id, list_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), input.id, buildListSnapshot(list, entries), input.message, input.ip_address); + return list; + })() as BandList; +} + +export function updateBandList(id: string, input: UpdateListInput): void { + const db = getDb(); + db.transaction(() => { + db.prepare("UPDATE lists SET slug = ?, title = ?, description = ? WHERE id = ?").run( + input.slug, input.title, input.description, id + ); + db.prepare("DELETE FROM list_entries WHERE list_id = ?").run(id); + input.entries.forEach((e, i) => { + db.prepare( + "INSERT INTO list_entries (id, list_id, band_name, note, order_index) VALUES (?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), id, e.band_name, e.note, i); + }); + const list = getBandListById(id)!; + const entries = getListEntries(id); + db.prepare( + "INSERT INTO list_revisions (id, list_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)" + ).run(crypto.randomUUID(), id, buildListSnapshot(list, entries), input.message, input.ip_address); + })(); +} + +function buildListSnapshot(list: BandList, entries: ListEntry[]): string { + return JSON.stringify({ + title: list.title, + description: list.description, + entries: entries.map((e) => ({ band_name: e.band_name, note: e.note })), + }); +} + // ── Export / Import ─────────────────────────────────────────────────────────── export interface DbExport { diff --git a/app/root.tsx b/app/root.tsx index 26bfb48..242ffe2 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -51,6 +51,7 @@ export default function App() { whois.band + Band + Artist + Lists diff --git a/app/routes.ts b/app/routes.ts index 1d8d60b..b02a70a 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -16,4 +16,10 @@ export default [ route("/artists/named/:slug", "routes/artist-by-slug.tsx"), route("/artists/of/:uuid/edit", "routes/artist-edit.tsx"), route("/artists/of/:uuid/history", "routes/artist-history.tsx"), + route("/lists", "routes/list-index.tsx"), + route("/lists/new", "routes/list-new.tsx"), + route("/lists/of/:uuid", "routes/list-by-uuid.tsx"), + route("/lists/named/:slug", "routes/list-by-slug.tsx"), + route("/lists/of/:uuid/edit", "routes/list-edit.tsx"), + route("/lists/of/:uuid/history", "routes/list-history.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/list-by-slug.tsx b/app/routes/list-by-slug.tsx new file mode 100644 index 0000000..902c74e --- /dev/null +++ b/app/routes/list-by-slug.tsx @@ -0,0 +1,13 @@ +import { data, redirect } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListBySlug } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListBySlug(params.slug!); + if (!list) throw data("Not found", { status: 404 }); + return redirect(`/lists/of/${list.id}`); +} + +export default function ListBySlug() { + return null; +} diff --git a/app/routes/list-by-uuid.tsx b/app/routes/list-by-uuid.tsx new file mode 100644 index 0000000..f8c7380 --- /dev/null +++ b/app/routes/list-by-uuid.tsx @@ -0,0 +1,54 @@ +import { data, Link, useLoaderData } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListById, getListEntries, getListRevisions } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const entries = getListEntries(list.id); + const revisions = getListRevisions(list.id); + return { list, entries, latest: revisions[0] ?? null }; +} + +export default function ListDetail() { + const { list, entries, latest } = useLoaderData(); + + return ( +
+
+
+

{list.title}

+ {list.description &&

{list.description}

} +
+
+ 履歴 + 編集 +
+
+ + {entries.length === 0 ? ( +

エントリがありません。

+ ) : ( +
    + {entries.map((entry) => ( +
  • +
    {entry.band_name}
    + {entry.note &&
    {entry.note}
    } +
  • + ))} +
+ )} + +
+
+

/lists/of/{list.id}

+

+ /lists/named/{list.slug} +

+ {latest && ( +

最終更新: {latest.created_at} — {latest.message}

+ )} +
+
+ ); +} diff --git a/app/routes/list-edit.tsx b/app/routes/list-edit.tsx new file mode 100644 index 0000000..5d47737 --- /dev/null +++ b/app/routes/list-edit.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { + getBandListById, + getListEntries, + getIpAddress, + updateBandList, + type ListEntryInput, +} from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const entries = getListEntries(list.id); + return { list, entries }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + + const fd = await request.formData(); + const title = (fd.get("title") as string).trim(); + const slug = (fd.get("slug") as string).trim(); + const description = (fd.get("description") as string).trim(); + const message = (fd.get("message") as string).trim(); + const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]"); + + const errors: Record = {}; + if (!title) errors.title = "必須です"; + if (!slug) errors.slug = "必須です"; + if (!message) errors.message = "必須です"; + if (Object.keys(errors).length > 0) return { errors }; + + try { + updateBandList(list.id, { slug, title, description, entries, message, ip_address: getIpAddress(request) }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) { + return { errors: { slug: "このslugは既に使用されています" } }; + } + throw e; + } + return redirect(`/lists/of/${list.id}`); +} + +type EntryRow = { key: string; band_name: string; note: string }; + +export default function ListEdit() { + const { list, entries: initEntries } = useLoaderData(); + const actionData = useActionData(); + const errors = actionData?.errors ?? {}; + + const [title, setTitle] = useState(list.title); + const [slug, setSlug] = useState(list.slug); + const [description, setDescription] = useState(list.description); + const [entries, setEntries] = useState( + initEntries.map((e) => ({ key: crypto.randomUUID(), band_name: e.band_name, note: e.note })) + ); + + function addEntry() { + setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]); + } + + function removeEntry(key: string) { + setEntries((prev) => prev.filter((e) => e.key !== key)); + } + + function updateEntry(key: string, field: "band_name" | "note", value: string) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } + + return ( +
+
+ ← +

{list.title} — 編集

+
+ +
+ ({ band_name, note })))} /> + +
+ + setTitle(e.target.value)} /> + {errors.title &&

{errors.title}

} +
+ +
+ + setSlug(e.target.value)} className="mono" /> + {errors.slug &&

{errors.slug}

} +
+ +
+ + setDescription(e.target.value)} /> +
+ +
+ +
+ {entries.map((entry) => ( +
+ updateEntry(entry.key, "band_name", e.target.value)} + placeholder="バンド名" + /> + updateEntry(entry.key, "note", e.target.value)} + placeholder="メモ" + /> + +
+ ))} +
+ +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + キャンセル +
+
+
+ ); +} diff --git a/app/routes/list-history.tsx b/app/routes/list-history.tsx new file mode 100644 index 0000000..c47614b --- /dev/null +++ b/app/routes/list-history.tsx @@ -0,0 +1,48 @@ +import { data, Link, useLoaderData } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListById, getListRevisions } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const revisions = getListRevisions(list.id); + return { list, revisions }; +} + +export default function ListHistory() { + const { list, revisions } = useLoaderData(); + return ( +
+
+ ← +

{list.title} — 編集履歴

+
+ + {revisions.length === 0 ? ( +

履歴がありません。

+ ) : ( +
    + {revisions.map((rev, i) => { + let snap: { title?: string; entries?: unknown[] } = {}; + try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ } + return ( +
  1. +
    +
    +

    {rev.message}

    +

    {rev.created_at} · {rev.ip_address}

    +
    + {i === 0 && 最新} +
    +
    +

    タイトル: {snap.title ?? "—"}

    +

    エントリ数: {snap.entries?.length ?? 0}件

    +
    +
  2. + ); + })} +
+ )} +
+ ); +} diff --git a/app/routes/list-index.tsx b/app/routes/list-index.tsx new file mode 100644 index 0000000..7995068 --- /dev/null +++ b/app/routes/list-index.tsx @@ -0,0 +1,36 @@ +import { Link, useLoaderData } from "react-router"; +import { listBandLists } from "~/lib/db.server"; + +export function loader() { + return { lists: listBandLists() }; +} + +export default function ListIndex() { + const { lists } = useLoaderData(); + return ( +
+
+

Lists

+ + List +
+ + {lists.length === 0 ? ( +

+ リストがまだありません。{" "} + 作成する +

+ ) : ( +
    + {lists.map((list) => ( +
  • + {list.title} + {list.description && ( + {list.description} + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/routes/list-new.tsx b/app/routes/list-new.tsx new file mode 100644 index 0000000..472f0d8 --- /dev/null +++ b/app/routes/list-new.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { Form, Link, redirect, useActionData } from "react-router"; +import type { ActionFunctionArgs } from "react-router"; +import { createBandList, getIpAddress, type ListEntryInput } from "~/lib/db.server"; + +export async function action({ request }: ActionFunctionArgs) { + const fd = await request.formData(); + const title = (fd.get("title") as string).trim(); + const slug = (fd.get("slug") as string).trim(); + const description = (fd.get("description") as string).trim(); + const message = (fd.get("message") as string).trim(); + const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]"); + + const errors: Record = {}; + if (!title) errors.title = "必須です"; + if (!slug) errors.slug = "必須です"; + if (!message) errors.message = "必須です"; + if (Object.keys(errors).length > 0) return { errors }; + + const id = crypto.randomUUID(); + try { + createBandList({ id, slug, title, description, entries, message, ip_address: getIpAddress(request) }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) { + return { errors: { slug: "このslugは既に使用されています" } }; + } + throw e; + } + return redirect(`/lists/of/${id}`); +} + +function toSlug(s: string) { + return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, ""); +} + +type EntryRow = { key: string; band_name: string; note: string }; + +export default function ListNew() { + const actionData = useActionData(); + const errors = actionData?.errors ?? {}; + + const [title, setTitle] = useState(""); + const [slug, setSlug] = useState(""); + const [slugManual, setSlugManual] = useState(false); + const [description, setDescription] = useState(""); + const [entries, setEntries] = useState([]); + + function addEntry() { + setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]); + } + + function removeEntry(key: string) { + setEntries((prev) => prev.filter((e) => e.key !== key)); + } + + function updateEntry(key: string, field: "band_name" | "note", value: string) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } + + return ( +
+
+ ← +

New List

+
+ +
+ ({ band_name, note })))} /> + +
+ + { setTitle(e.target.value); if (!slugManual) setSlug(toSlug(e.target.value)); }} + /> + {errors.title &&

{errors.title}

} +
+ +
+ + { setSlugManual(true); setSlug(e.target.value); }} + className="mono" + /> + {errors.slug &&

{errors.slug}

} +
+ +
+ + setDescription(e.target.value)} /> +
+ +
+ +
+ {entries.map((entry) => ( +
+ updateEntry(entry.key, "band_name", e.target.value)} + placeholder="バンド名" + /> + updateEntry(entry.key, "note", e.target.value)} + placeholder="メモ" + /> + +
+ ))} +
+ +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + キャンセル +
+
+
+ ); +} diff --git a/scripts/seed-mosquitone.ts b/scripts/seed-mosquitone.ts new file mode 100644 index 0000000..e3d532d --- /dev/null +++ b/scripts/seed-mosquitone.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env npx tsx +/** + * Seeds a "mosquitone と共演したバンド" list by fetching Gatsby page-data JSON. + * Usage: npx tsx scripts/seed-mosquitone.ts + * Override DB path: DB_PATH=/path/to/whois.db npx tsx scripts/seed-mosquitone.ts + */ + +import { createBandList, getBandListBySlug, toSlug } from "../app/lib/db.server"; + +interface LiveEvent { + date: string; + venueName: string; + description: string; + title: string; + published?: boolean; + [key: string]: unknown; +} + +interface PageData { + result?: { + data?: { + amplify?: { + listEvents?: { + items: LiveEvent[]; + }; + }; + }; + }; +} + +const PAGE_DATA_URL = "https://www.mosquit.one/page-data/live/page-data.json"; + +async function main() { + console.log("Fetching mosquit.one live data..."); + const res = await fetch(PAGE_DATA_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json() as PageData; + + const items = json?.result?.data?.amplify?.listEvents?.items ?? []; + console.log(`Found ${items.length} events`); + + // band_name → [{ date, venue }] + const coPerformers = new Map(); + + for (const event of items) { + const rawDesc = event.description ?? ""; + const date = event.date ?? ""; + const venue = (event.venueName ?? "").trim(); + + // Split on actual newlines or literal \n, then find the performer line + // (some events have OPEN/START on the first line and performers on the second) + const lines = rawDesc.split(/\n|\\n/); + const perfLine = lines.find((l) => + !l.includes("://") && ( // skip URL-containing lines + l.includes("//") || (l.split("/").length > 2 && !/^\d/.test(l.trim())) + ) + ); + if (!perfLine) continue; + + // Remove "出演:" / "出演者" prefix + const cleaned = perfLine.replace(/^(出演者?[::]?\s*)/u, "").trim(); + + // Must still contain // or " / " after cleanup + if (!cleaned.includes("//") && !cleaned.includes(" / ")) continue; + + // Split by " // " or " / " + const parts = cleaned.split(/\s*\/\/\s*|\s*\/\s*/); + + for (const raw of parts) { + // Remove parenthetical suffixes like "(東京)" "(いわき)" + let name = raw.replace(/[\((][^))]*[\))]/gu, "").trim(); + // Remove any remaining literal \n and opening-paren-without-close + name = name.replace(/\\n/g, "").replace(/[\((].*$/, "").trim(); + // Remove stray closing parentheses + name = name.replace(/[\))]/g, "").trim(); + + // Validation filters + if (!name || name.length < 2) continue; + if (/mosquitone/iu.test(name)) continue; + if (/^\d/.test(name)) continue; // starts with number (dates etc.) + if (/\d{1,2}:\d{2}/.test(name)) continue; // contains time HH:MM + if (/^(OPEN|START)/i.test(name)) continue; + if (/出演|二日間|開催|開場/.test(name)) continue; + if (/\.(com|net|jp|org|co)\b/.test(name)) continue; // domain names + if (name.length > 60) continue; + + const appearances = coPerformers.get(name) ?? []; + appearances.push({ date, venue }); + coPerformers.set(name, appearances); + } + } + + console.log(`Parsed ${coPerformers.size} co-performers`); + + // Build entries sorted by band name + const entries = [...coPerformers.entries()] + .sort(([a], [b]) => a.localeCompare(b, "ja")) + .map(([band_name, appearances]) => { + appearances.sort((a, b) => a.date.localeCompare(b.date)); + const note = appearances + .map(({ date, venue }) => { + const displayDate = date.replace(/-/g, "/"); + return venue ? `${displayDate} に ${venue} で共演` : `${displayDate} に共演`; + }) + .join("、"); + return { band_name, note }; + }); + + const title = "mosquitone と共演したバンド"; + const slug = toSlug(title); + + // Check for existing list + const existing = getBandListBySlug(slug); + if (existing) { + console.log(`List already exists (slug: "${slug}"), skipping.`); + console.log(` url: /lists/of/${existing.id}`); + return; + } + + const id = crypto.randomUUID(); + const list = createBandList({ + id, + slug, + title, + description: "mosquitone の共演バンドを自動収集したリストです。", + entries, + message: "seed-mosquitone スクリプトによる自動生成", + ip_address: "cli", + }); + + console.log(`Created list: ${list.title}`); + console.log(` id: ${list.id}`); + console.log(` entries: ${entries.length}件`); + console.log(` url: /lists/of/${list.id}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); -- cgit v1.2.3