diff --git a/README.md b/README.md index 1909f4b..92ba1eb 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ and external services. Never enable it in a deployed environment. | `bun run db:generate` | Generate a Drizzle migration from schema changes | | `bun run db:migrate` | Apply committed Drizzle migrations | | `bun run db:seed` | Upsert the built-in templates | +| `bun run import:guides` | Preview the one-time Google Sheets guide migration (`-- --apply` to write) | | `bun run worker:discord` | Listen for Lunaris update announcements | | `bun run worker:outbox` | Process cache and event outbox records | diff --git a/app/register/page.tsx b/app/register/page.tsx index 14e7261..6259315 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -9,12 +9,12 @@ export default async function RegisterPage() { await connection(); if (await getAdminSession()) redirect("/admin"); if (await !getAdminSession()) redirect("/"); - return ( null - //
- // - //
- // - //
- //
+ return ( +
+ +
+ +
+
); } diff --git a/bun.lock b/bun.lock index 83030f9..5da4240 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "discord.js": "^14.27.0", "drizzle-orm": "^0.45.2", "embla-carousel-react": "^8.6.0", + "fflate": "^0.8.2", "ioredis": "^6.0.0", "lucide-react": "^1.35.0", "modern-screenshot": "^4.7.0", @@ -953,6 +954,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c5bcba8..35a9553 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1788411911516, "tag": "0020_aberrant_dark_beast", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1788420000000, + "tag": "0021_optional_team_rotation", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/lib/guides/google-sheet-import.test.ts b/lib/guides/google-sheet-import.test.ts new file mode 100644 index 0000000..3abf680 --- /dev/null +++ b/lib/guides/google-sheet-import.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildGuideImportDraft, + charactersFromCombo, + parseCsv, + type GuideImportSource, + type ImportCatalog, +} from "@/lib/guides/google-sheet-import"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Google Sheet guide import", () => { + it("parses quoted commas, escaped quotes, and multiline cells", () => { + expect(parseCsv('"name","note"\n"A, B","line 1\nline ""2"""\n')).toEqual([ + ["name", "note"], + ["A, B", 'line 1\nline "2"'], + ]); + }); + + it("builds supported compact fields and reports missing catalog rows", async () => { + const source: GuideImportSource = { + characterName: "Hero", + overview: "Guide Hero", + workbookId: "sheet-id", + coverId: "cover-id", + layout: "compact", + infoSheet: "Hero Info", + teamSheets: [], + comboSheets: [], + weaponMetric: "overall-personal", + }; + const catalog: ImportCatalog = { + characters: [{ key: "hero", name: "Hero", constellations: [] }], + weapons: [{ key: "weapon", name: "Known Weapon" }], + artifacts: [{ key: "artifact", name: "Known Set" }], + }; + const rows = Array.from({ length: 24 }, () => Array.from({ length: 21 }, () => "")); + rows[0][10] = "Weapons อาวุธที่แนะนำ"; + rows[1][11] = "Known Weapon R5"; + rows[1][13] = "110%"; + rows[1][14] = "105%"; + rows[3][11] = "Missing Weapon R1"; + rows[3][13] = "100%"; + rows[5][10] = "Artifacts อาร์ติแฟกต์ที่แนะนำ (เลทเกม)"; + rows[6][11] = "4 Known Set"; + rows[6][13] = "100%"; + rows[8][5] = "ATK (พลังโจมตี)"; + rows[8][6] = "2000+"; + rows[8][7] = "สแตทที่แนะนำ เลทเกม (AR 45+)"; + vi.stubGlobal("fetch", vi.fn(async () => new Response(rows.map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n")))); + + const draft = await buildGuideImportDraft(source, catalog); + + expect(draft.weapons).toEqual([expect.objectContaining({ weaponKey: "weapon", refinement: 5, overallPercent: 110, personalPercent: 105 })]); + expect(draft.artifacts.groups[0].rows).toEqual([expect.objectContaining({ firstArtifactKey: "artifact", percent: 100 })]); + expect(draft.artifacts.targets).toEqual([{ stat: "ATK", value: "2000+" }]); + expect(draft.skipped).toContain("weapon not in catalog: Missing Weapon R1"); + }); + + it("does not fetch or populate a keep-existing source", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const draft = await buildGuideImportDraft({ + characterName: "Yumemizuki Mizuki", + overview: "existing", + workbookId: "sheet-id", + coverId: "cover-id", + layout: "full", + infoSheet: "Mizuki Info", + teamSheets: [], + comboSheets: [], + skipWrite: true, + }, { + characters: [{ key: "mizuki", name: "Yumemizuki Mizuki", constellations: [] }], + weapons: [], + artifacts: [], + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(draft.weapons).toEqual([]); + }); + + it("resolves the unique characters named by a combo", () => { + const characters = ["Yae Miko", "Odette", "Alyosha", "Sandrone"].map((name, index) => ({ + key: String(index), + name, + constellations: [], + })); + expect(charactersFromCombo("Yae EEE > Odette EE > Alyosha EQ > Sandrone 3CE Q", { + characters, + weapons: [], + artifacts: [], + }).map((item) => item.name)).toEqual(["Yae Miko", "Odette", "Alyosha", "Sandrone"]); + }); +}); diff --git a/lib/guides/google-sheet-import.ts b/lib/guides/google-sheet-import.ts new file mode 100644 index 0000000..7707849 --- /dev/null +++ b/lib/guides/google-sheet-import.ts @@ -0,0 +1,1388 @@ +import { posix as path } from "node:path"; + +import { unzipSync } from "fflate"; +import sharp from "sharp"; + +export type SheetMatrix = string[][]; + +export interface GuideImportSource { + characterName: string; + overview: string; + workbookId: string; + coverId: string; + layout: "full" | "compact"; + infoSheet: string; + weaponMetric?: "overall-personal" | "personal-only" | "primary-only"; + teamSheets: string[]; + comboSheets: string[]; + skipWrite?: boolean; +} + +export interface ImportCatalogItem { + key: string; + name: string; + imageUrl?: string; +} + +export interface ImportCatalogCharacter extends ImportCatalogItem { + constellations: Array<{ level: number; description: string }>; +} + +export interface ImportCatalog { + characters: ImportCatalogCharacter[]; + weapons: ImportCatalogItem[]; + artifacts: ImportCatalogItem[]; +} + +interface WorkbookVisual { + characterKey?: string; + weaponKey?: string; + artifactKey?: string; +} + +type WorkbookVisuals = Map; + +export interface GuideImportDraft { + source: GuideImportSource; + characterKey: string; + weapons: Array<{ + weaponKey: string; + refinement: number; + overallPercent: number | null; + personalPercent: number | null; + note: string | null; + }>; + artifacts: { + groups: Array<{ + name: "Late Game" | "Early Game"; + rows: Array<{ + firstArtifactKey: string; + secondArtifactKey: string | null; + percent: number; + note: string | null; + }>; + }>; + sands: string[]; + goblet: string[]; + circlet: string[]; + substats: string[]; + targets: Array<{ stat: string; value: string }>; + talents: Array<{ + type: "NA" | "E" | "Q"; + priority: number; + level?: string; + relationToNext?: "gt" | "gte" | "eq"; + }>; + note: string | null; + }; + constellationGroups: Array<{ + note: string | null; + rows: Array<{ + level: number; + detail: string; + cumulativePercent: number; + isGreat: boolean; + }>; + }>; + teams: Array<{ + title: string; + rotationSeconds: number | null; + combo: string; + members: Array<{ + characterKey: string; + weaponKey: string | null; + artifactKey: string | null; + secondArtifactKey: string | null; + constellation: number; + refinement: number; + damage: number; + }>; + }>; + extras: Array<{ + title: string; + rows: Array<{ + heading: string; + body: string; + percent: number | null; + }>; + }>; + skipped: string[]; +} + +export const guideImportSources: GuideImportSource[] = [ + { + characterName: "Yumemizuki Mizuki", + overview: "[7.1] Full Guide Mizuki", + workbookId: "1bLEKBOTMZ6H3NmxEM7o32HQgNqcRBR0pUV-OIeACpXA", + coverId: "01a0387a-4313-7d9a-b2ff-38e9356ccd47", + layout: "full", + infoSheet: "Mizuki Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "overall-personal", + }, + { + characterName: "Odette", + overview: "[7.0] Full Guide Odette", + workbookId: "1I7iuPbigKD6OZ5t4M9-tE5AvP-BMAu7iJB54NilP6-o", + coverId: "01a0040b-0f75-7b20-9a87-e6ec105bd803", + layout: "full", + infoSheet: "Odette Info", + teamSheets: ["Team Calc"], + comboSheets: ["Combo (คอมโบ)"], + weaponMetric: "overall-personal", + }, + { + characterName: "Yae Miko", + overview: "[7.0] Full Guide Yae Miko", + workbookId: "1kUIsa7UmNc4WbhgRvL99wr0i2pD1rezcr8nWndRibFc", + coverId: "019f3848-2d0d-7295-9d3c-ae78e73752d4", + layout: "full", + infoSheet: "Yae Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "overall-personal", + }, + { + characterName: "Mavuika", + overview: "[6.7] Full Guide Mavuika", + workbookId: "1DQIREJUi0RKqIgTDThCbgkgLrwPCqun6fL1D1TswjiU", + coverId: "019eb0ef-0a48-7109-ad20-1d3d1d0685cc", + layout: "compact", + infoSheet: "Mavuika Info", + teamSheets: ["คอมโบแบบลิง Team Calc"], + comboSheets: ["Combo"], + weaponMetric: "primary-only", + }, + { + characterName: "Sandrone", + overview: "[6.7] Full Guide Sandrone", + workbookId: "1YqRlAlO_q5EyL38j4E5Z57qztxUBS5muEChS0qC0E8s", + coverId: "019e78b4-9b68-7a07-b1ce-75aaf020b54e", + layout: "compact", + infoSheet: "Sandrone Info", + teamSheets: ["Team Calc", "ชีต7"], + comboSheets: [], + weaponMetric: "overall-personal", + }, + { + characterName: "Nicole", + overview: "[6.6] Nicole Guide", + workbookId: "1vacoQds_seHc-4d-4Nhy2LIwkbwrRaTkn3a1epdWDF8", + coverId: "019e31ca-98ba-792c-bbe4-b35e2ccb685e", + layout: "compact", + infoSheet: "Nicole Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Lohen", + overview: "[6.6] Mini Guide Lohen", + workbookId: "1TH_5ve6i-COCe5thV37Z8ZpXiWWoJdklfsJHG9sNmf4", + coverId: "019d86ab-bfd5-77ad-b15f-1f590724f932", + layout: "compact", + infoSheet: "Lohen Info", + teamSheets: ["Team Calc", "ชีต7"], + comboSheets: [], + weaponMetric: "overall-personal", + }, + { + characterName: "Nefer", + overview: "[6.5] Nefer Guide", + workbookId: "1bABpC7CTRRZaJ7EDZnzBcNjTTKSZhZQhR_Ee42I7mW0", + coverId: "019d5413-370e-771a-b402-9d064f517e12", + layout: "compact", + infoSheet: "Nefer Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Linnea", + overview: "[6.5] Linnea Guide", + workbookId: "1GLRUNkJcWvLO6FdMt8wgazE1Yic7-mIryLnXIJczyNc", + coverId: "019d07c6-80eb-7cc1-bc32-275125b0694e", + layout: "compact", + infoSheet: "Linnea Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Varka", + overview: "[6.4] Varka Guide", + workbookId: "1wxN1vfbpSqQagJexiT5K0gvioP8Rg_qOuBr514JlNcQ", + coverId: "019d07c6-8614-7495-a7d7-dddfda786cf3", + layout: "compact", + infoSheet: "Varka Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Zibai", + overview: "[6.3] Zibai Guide", + workbookId: "1LZ0nN6tREKa32whGqHQSPTdM4KTX4CxA6sKY2PgzByA", + coverId: "019d07c6-83d7-7f56-a1f6-8d500deb17bb", + layout: "compact", + infoSheet: "Zibai Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Ineffa", + overview: "[6.3] Ineffa Guide", + workbookId: "1ZvZ76gmczMs_L6gcXLW5TgffU0JjQrCqR42aNPELiFs", + coverId: "019d07c6-8574-7e4b-8b9b-48e3c08be240", + layout: "compact", + infoSheet: "Ineffa Info", + teamSheets: ["Team Calc", "Team Calc No Columbina"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Columbina", + overview: "[6.3] Columbina Guide", + workbookId: "12Q2CBMvGgldysO2IENh6O6TXoVL_iqv0VTSve0QHz98", + coverId: "019d07c6-8792-7923-ac39-b2f16cebc40c", + layout: "compact", + infoSheet: "Columbina Info", + teamSheets: [ + "Columbina SubDPSSupport | Nodkr", + "Columbina SubDPSSupport | Other", + "Columbina MainDPS (On-field)", + ], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Mona", + overview: "[6.3] Mona ไสยเวทย์ Guide", + workbookId: "1GlQWI7J79G6ezUoOwRWyn__TEdEzsd8oee3RGUYoZZA", + coverId: "019d07c6-7fd4-7156-8551-e25cc335022f", + layout: "compact", + infoSheet: "Mona Info", + teamSheets: ["Mona Support", "Mona On field"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Flins", + overview: "[6.3] Flins Guide", + workbookId: "19exQORI29IDvI29f064cgQgp4jtViY9Co0h-y2A0k9w", + coverId: "019d07c6-8314-779f-bfda-4fe82129b14e", + layout: "compact", + infoSheet: "Flins Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, + { + characterName: "Fischl", + overview: "[6.3] Fischl ไสยเวทย์ Guide", + workbookId: "10e1fXcfubIqljb2FFfltM6sAKE6whcHujkSpipW-I7Y", + coverId: "019d07c6-869e-72fd-a48c-3bd21688a8a9", + layout: "compact", + infoSheet: "Fischl Info", + teamSheets: ["Team Calc"], + comboSheets: [], + weaponMetric: "primary-only", + }, +]; + +const itemAliases: Record = { + "4vv": "Viridescent Venerer", + vv: "Viridescent Venerer", + ttds: "Thrilling Tales of Dragon Slayers", + redhorn: "Redhorn Stonethresher", + wolfgrave: "Wolf's Gravestone", + wolfgravestone: "Wolf's Gravestone", + theunforge: "The Unforged", + primodialjade: "Primordial Jade Winged-Spear", + primordialjade: "Primordial Jade Winged-Spear", + primordialjader1: "Primordial Jade Winged-Spear", + primodialjadecutter: "Primordial Jade Cutter", + kaguraverity: "Kagura's Verity", + athounsandfloatingdreams: "A Thousand Floating Dreams", + dawingfrost: "Dawning Frost", + stellarwatch: "Starcaller's Watch", + nocturnecurtaincall: "Nocturne's Curtain Call", + thounsandblazingsuns: "A Thousand Blazing Suns", + thousandblazingsun: "A Thousand Blazing Suns", + thousandblazingsuns: "A Thousand Blazing Suns", + mightywolf: "Gest of the Mighty Wolf", + balladofthemightywolf: "Gest of the Mighty Wolf", + daybreakchronicles: "The Daybreak Chronicles", + eternalflow: "Tome of the Eternal Flow", + hunterpath: "Hunter's Path", + nightskyunveiling: "Night of the Sky's Unveiling", + nightoftheskys: "Night of the Sky's Unveiling", + marechaussehunter: "Marechaussee Hunter", + carvedfromrisingwinds: "A Day Carved From Rising Winds", + risingwinds: "A Day Carved From Rising Winds", + desertpavillionchronicle: "Desert Pavilion Chronicle", + aubadeofmorningstar: "Aubade of Morningstar and Moon", + calamity: "Calamity Queller", + beacon: "Beacon of the Reed Sea", + floatingdreams: "A Thousand Floating Dreams", + tidalshadow: "Tidal Shadow", + crimsonwitch: "Crimson Witch of Flames", + marechauseehunter: "Marechaussee Hunter", + noblesse: "Noblesse Oblige", + tenacity: "Tenacity of the Millelith", + gildeddream: "Gilded Dreams", + scarletproof: "Scarlet Proof", + resolution: "Resolution of Sojourner", + instructor: "Instructor", +}; + +const characterAliases: Record = { + mizuki: "Yumemizuki Mizuki", + cryomc: "Traveler Cryo", + anemomc: "Traveler Anemo", + geomc: "Traveler Geo", + dendromc: "Traveler Dendro", + hydromc: "Traveler Hydro", + pyromc: "Traveler Pyro", + electromc: "Traveler Electro", +}; + +function normalized(value: string): string { + return value + .normalize("NFKD") + .toLowerCase() + .replace(/\([^)]*\)/gu, " ") + .replace(/\br(?:efinement\s*)?[1-5](?:-[1-5])?\b/giu, " ") + .replace(/^\s*[24]\s+/u, "") + .replace(/[^a-z0-9]+/gu, ""); +} + +function cleanLabel(value: string): string { + return value + .split("\n")[0] + .trim() + .replace(/^\s*[24]\s+/u, ""); +} + +function resolveItem( + value: string, + items: ImportCatalogItem[], +): ImportCatalogItem | null { + const source = normalized(cleanLabel(value)); + const alias = itemAliases[source]; + const wanted = normalized(alias ?? source); + const exact = items.find((item) => normalized(item.name) === wanted); + if (exact) return exact; + if (wanted.length < 5) return null; + const prefix = items.filter((item) => { + const candidate = normalized(item.name); + return candidate.startsWith(wanted) || wanted.startsWith(candidate); + }); + return prefix.length === 1 ? prefix[0] : null; +} + +function resolveCharacter( + value: string, + items: ImportCatalogCharacter[], +): ImportCatalogCharacter | null { + const withoutBuild = cleanLabel(value) + .replace(/\s+C[0-6](?:\s+R[1-5])?.*$/iu, "") + .trim(); + const source = normalized(withoutBuild); + const wanted = normalized(characterAliases[source] ?? source); + const exact = items.find((item) => normalized(item.name) === wanted); + if (exact) return exact; + if (wanted.length < 4) return null; + const prefix = items.filter((item) => + normalized(item.name).startsWith(wanted), + ); + return prefix.length === 1 ? prefix[0] : null; +} + +function percent(value: string): number | null { + if (/^\s*-\s*%?\s*$/u.test(value)) return 0; + const match = /(-?\d+(?:\.\d+)?)\s*%/u.exec(value.replace(/,/gu, "")); + return match ? Number(match[1]) : null; +} + +function numeric(value: string): number | null { + const cleaned = value.replace(/,/gu, "").trim(); + if (!/^-?\d+(?:\.\d+)?$/u.test(cleaned)) return null; + return Number(cleaned); +} + +function refinement(value: string): number { + const match = /\bR([1-5])(?:-[1-5])?\b/iu.exec(value); + return match ? Number(match[1]) : 1; +} + +function noteFromLabel(value: string): string | null { + const lines = value + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.length > 1 ? lines.slice(1).join("\n") : null; +} + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} + +export function parseCsv(value: string): SheetMatrix { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quoted) { + if (character === '"' && value[index + 1] === '"') { + field += '"'; + index += 1; + } else if (character === '"') quoted = false; + else field += character; + } else if (character === '"') quoted = true; + else if (character === ",") { + row.push(field); + field = ""; + } else if (character === "\n") { + row.push(field.replace(/\r$/u, "")); + rows.push(row); + row = []; + field = ""; + } else field += character; + } + if (field || row.length) { + row.push(field.replace(/\r$/u, "")); + rows.push(row); + } + return rows; +} + +export async function fetchSheetCsv( + source: GuideImportSource, + sheet: string, + range: string, +): Promise { + const url = new URL( + `https://docs.google.com/spreadsheets/d/${source.workbookId}/gviz/tq`, + ); + url.searchParams.set("tqx", "out:csv"); + url.searchParams.set("headers", "0"); + url.searchParams.set("sheet", sheet); + url.searchParams.set("range", range); + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) + throw new Error( + `${source.characterName}: ${sheet} returned ${response.status}`, + ); + const body = await response.text(); + if (/^>(); + +async function imageFingerprint(input: Uint8Array | ArrayBuffer): Promise { + return new Uint8Array( + await sharp(Buffer.from(input instanceof ArrayBuffer ? new Uint8Array(input) : input)) + .flatten({ background: "#000" }) + .resize(16, 16, { fit: "fill" }) + .raw() + .toBuffer(), + ); +} + +function catalogFingerprint(item: ImportCatalogItem): Promise { + if (!item.imageUrl) return Promise.resolve(null); + const cached = assetFingerprintCache.get(item.imageUrl); + if (cached) return cached; + const loading = fetch(item.imageUrl, { signal: AbortSignal.timeout(30_000) }) + .then(async (response) => + response.ok ? imageFingerprint(await response.arrayBuffer()) : null, + ) + .catch(() => null); + assetFingerprintCache.set(item.imageUrl, loading); + return loading; +} + +function fingerprintDistance(left: Uint8Array, right: Uint8Array): number { + let total = 0; + for (let index = 0; index < left.length; index += 1) { + const difference = left[index] - right[index]; + total += difference * difference; + } + return Math.sqrt(total / left.length); +} + +async function matchCatalogImage( + fingerprint: Uint8Array, + items: T[], +): Promise { + const candidates = ( + await Promise.all( + items.map(async (item) => ({ item, value: await catalogFingerprint(item) })), + ) + ) + .filter( + (candidate): candidate is { item: T; value: Uint8Array } => + candidate.value !== null, + ) + .map((candidate) => ({ + item: candidate.item, + distance: fingerprintDistance(fingerprint, candidate.value), + })) + .sort((left, right) => left.distance - right.distance); + const best = candidates[0]; + const runnerUp = candidates[1]; + return best && + best.distance <= 25 && + (!runnerUp || runnerUp.distance - best.distance >= 5) + ? best.item + : null; +} + +function xmlValue(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function xmlAttributes(tag: string): Record { + return Object.fromEntries( + [...tag.matchAll(/([\w:.-]+)="([^"]*)"/gu)].map((match) => [ + match[1], + xmlValue(match[2]), + ]), + ); +} + +function archiveText( + files: Record, + fileName: string, +): string | null { + const value = files[fileName]; + return value ? new TextDecoder().decode(value) : null; +} + +function relationshipMap(xml: string): Map { + return new Map( + [...xml.matchAll(/]*>/gu)].map((match) => { + const attributes = xmlAttributes(match[0]); + return [attributes.Id, attributes.Target]; + }), + ); +} + +function relatedPath(source: string, target: string): string { + return path.normalize(path.join(path.dirname(source), target)); +} + +async function fetchWorkbookVisuals( + source: GuideImportSource, + catalog: ImportCatalog, +): Promise { + if ( + !source.teamSheets.length || + !catalog.characters.some((item) => item.imageUrl) + ) + return new Map(); + const response = await fetch( + `https://docs.google.com/spreadsheets/d/${source.workbookId}/export?format=xlsx`, + { signal: AbortSignal.timeout(60_000) }, + ); + if (!response.ok) + throw new Error(`${source.characterName}: workbook returned ${response.status}`); + const files = unzipSync(new Uint8Array(await response.arrayBuffer())); + const workbookPath = "xl/workbook.xml"; + const workbook = archiveText(files, workbookPath); + const workbookRelationships = archiveText( + files, + "xl/_rels/workbook.xml.rels", + ); + if (!workbook || !workbookRelationships) return new Map(); + const worksheetById = relationshipMap(workbookRelationships); + const visuals: WorkbookVisuals = new Map(); + const imageMatches = new Map>(); + + for (const sheetTag of workbook.match(/]*>/gu) ?? []) { + const sheet = xmlAttributes(sheetTag); + if (!source.teamSheets.includes(sheet.name)) continue; + const worksheetTarget = worksheetById.get(sheet["r:id"]); + if (!worksheetTarget) continue; + const worksheetPath = relatedPath(workbookPath, worksheetTarget); + const worksheet = archiveText(files, worksheetPath); + const worksheetRelsPath = path.join( + path.dirname(worksheetPath), + "_rels", + `${path.basename(worksheetPath)}.rels`, + ); + const worksheetRelationships = archiveText(files, worksheetRelsPath); + if (!worksheet || !worksheetRelationships) continue; + const worksheetRelationshipMap = relationshipMap(worksheetRelationships); + const drawingId = /]*r:id="([^"]+)"/u.exec(worksheet)?.[1]; + const drawingTarget = drawingId + ? worksheetRelationshipMap.get(drawingId) + : null; + if (!drawingTarget) continue; + const drawingPath = relatedPath(worksheetPath, drawingTarget); + const drawing = archiveText(files, drawingPath); + const drawingRelsPath = path.join( + path.dirname(drawingPath), + "_rels", + `${path.basename(drawingPath)}.rels`, + ); + const drawingRelationships = archiveText(files, drawingRelsPath); + if (!drawing || !drawingRelationships) continue; + const drawingRelationshipMap = relationshipMap(drawingRelationships); + for (const anchorMatch of drawing.matchAll( + /([\s\S]*?)<\/xdr:(?:one|two)CellAnchor>/gu, + )) { + const anchor = anchorMatch[1]; + const column = Number(/(\d+)<\/xdr:col>/u.exec(anchor)?.[1]); + const row = Number(/(\d+)<\/xdr:row>/u.exec(anchor)?.[1]); + const imageId = /r:embed="([^"]+)"/u.exec(anchor)?.[1]; + const imageTarget = imageId ? drawingRelationshipMap.get(imageId) : null; + if (!Number.isInteger(column) || !Number.isInteger(row) || !imageTarget) + continue; + const imagePath = relatedPath(drawingPath, imageTarget); + const image = files[imagePath]; + if (!image) continue; + let matched = imageMatches.get(imagePath); + if (!matched) { + matched = imageFingerprint(image).then(async (fingerprint) => { + const [character, weapon, artifact] = await Promise.all([ + matchCatalogImage(fingerprint, catalog.characters), + matchCatalogImage(fingerprint, catalog.weapons), + matchCatalogImage(fingerprint, catalog.artifacts), + ]); + return { + ...(character ? { characterKey: character.key } : {}), + ...(weapon ? { weaponKey: weapon.key } : {}), + ...(artifact ? { artifactKey: artifact.key } : {}), + }; + }); + imageMatches.set(imagePath, matched); + } + visuals.set(`${sheet.name}\0${row}\0${column}`, await matched); + } + } + return visuals; +} + +function emptyDraft( + source: GuideImportSource, + characterKey: string, +): GuideImportDraft { + return { + source, + characterKey, + weapons: [], + artifacts: { + groups: [ + { name: "Late Game", rows: [] }, + { name: "Early Game", rows: [] }, + ], + sands: [], + goblet: [], + circlet: [], + substats: [], + targets: [], + talents: [], + note: null, + }, + constellationGroups: [], + teams: [], + extras: [], + skipped: [], + }; +} + +function addWeapon( + draft: GuideImportDraft, + catalog: ImportCatalog, + label: string, + overall: string, + personal: string, + metric: GuideImportSource["weaponMetric"], +): void { + if (!cleanLabel(label)) return; + const primaryPercent = percent(overall); + if (primaryPercent === null || /ชื่ออาวุธ/u.test(label)) return; + const weapon = resolveItem(label, catalog.weapons); + if (!weapon) { + draft.skipped.push(`weapon not in catalog: ${cleanLabel(label)}`); + return; + } + const secondary = percent(personal); + const overallPercent = metric === "personal-only" ? null : primaryPercent; + const personalPercent = + metric === "personal-only" + ? primaryPercent + : metric === "overall-personal" + ? secondary + : null; + const notes = [noteFromLabel(label)]; + if (metric !== "overall-personal" && secondary !== null) + notes.push(`ค่าคอลัมน์ที่สองจากชีต: ${secondary}%`); + draft.weapons.push({ + weaponKey: weapon.key, + refinement: refinement(label), + overallPercent, + personalPercent, + note: notes.filter(Boolean).join("\n") || null, + }); +} + +function compactWeaponMetric(matrix: SheetMatrix): GuideImportSource["weaponMetric"] { + const weaponHeader = matrix + .slice(0, 3) + .flat() + .find((cell) => /Weapons\s+อาวุธ/iu.test(cell)); + if (!/ดาเมจส่วนตัว|personal\s*(?:damage|dmg)/iu.test(weaponHeader ?? "")) + return "primary-only"; + return matrix.some( + (row) => percent(row[14] ?? "") !== null || percent(row[20] ?? "") !== null, + ) + ? "overall-personal" + : "personal-only"; +} + +function artifactParts(label: string): string[] { + const cleaned = cleanLabel(label); + if (/^(?:2\s*)?EM\s*\+\s*2\s*EM$/iu.test(cleaned)) + return ["Gilded Dreams", "Wanderer's Troupe"]; + if (/^(?:2\s*)?ATK\s*\+\s*2\s*ATK$/iu.test(cleaned)) + return ["Gladiator's Finale", "Shimenawa's Reminiscence"]; + if (/^Berserker\s*\+\s*2\s*ATK$/iu.test(cleaned)) + return ["Berserker", "Gladiator's Finale"]; + return cleaned + .split(/\s*\+\s*/u) + .map((item) => item.trim()) + .filter(Boolean); +} + +function addArtifact( + draft: GuideImportDraft, + catalog: ImportCatalog, + groupIndex: 0 | 1, + label: string, + metric: string, +): void { + if (!cleanLabel(label)) return; + const value = percent(metric); + if (value === null || /ชื่ออาร์ติแฟกต์/u.test(label)) return; + const parts = artifactParts(label); + if (!parts.length || parts.length > 2) { + draft.skipped.push( + `artifact combination cannot be represented: ${cleanLabel(label)}`, + ); + return; + } + const resolved = parts.map((part) => resolveItem(part, catalog.artifacts)); + if (resolved.some((item) => !item)) { + draft.skipped.push(`artifact not in catalog: ${cleanLabel(label)}`); + return; + } + draft.artifacts.groups[groupIndex].rows.push({ + firstArtifactKey: resolved[0]!.key, + secondArtifactKey: resolved[1]?.key ?? null, + percent: value, + note: noteFromLabel(label), + }); +} + +const targetAliases: Record = { + atk: "ATK", + hp: "HP", + def: "DEF", + em: "EM", + er: "ER", + cr: "CRIT RATE", + cdm: "CRIT DAMAGE", +}; + +function targetName(value: string): string | null { + const match = /\b(ATK|HP|DEF|EM|ER|CR|CDM)\b/iu.exec(value); + return match ? targetAliases[match[1].toLowerCase()] : null; +} + +function mainStat(value: string): string | null { + const text = value.trim(); + const map: Array<[RegExp, string]> = [ + [/ชำนาญธาตุ|^EM$/iu, "EM"], + [/ฟื้นฟูพลังงาน|^ER$/iu, "ER"], + [/พลังโจมตี|^ATK%$/iu, "ATK%"], + [/พลังชีวิต|^HP%$/iu, "HP%"], + [/พลังป้องกัน|^DEF%$/iu, "DEF%"], + [/อัตราคริ|แรงคริ|CRIT/iu, "CRIT Rate%/CRIT DMG%"], + [/โบนัสการรักษา|Healing/iu, "Healing Bonus%"], + ]; + return map.find(([pattern]) => pattern.test(text))?.[1] ?? null; +} + +function validSlotStat( + slot: "sands" | "goblet" | "circlet", + value: string, +): boolean { + const options = { + sands: ["EM", "ER", "ATK%", "HP%", "DEF%"], + goblet: [ + "EM", + "ATK%", + "DEF%", + "HP%", + "Pyro DMG%", + "Hydro DMG%", + "Cryo DMG%", + "Electro DMG%", + "Anemo DMG%", + "Geo DMG%", + "Dendro DMG%", + "Physical DMG%", + ], + circlet: [ + "HP%", + "ATK%", + "DEF%", + "EM", + "CRIT Rate%/CRIT DMG%", + "Healing Bonus%", + ], + } as const; + return (options[slot] as readonly string[]).includes(value); +} + +function talentType(value: string): "NA" | "E" | "Q" | null { + if (/การโจมตีปกติ|normal/iu.test(value)) return "NA"; + if (/สกิลธาตุ|elemental skill/iu.test(value)) return "E"; + if (/ท่าไม้ตาย|elemental burst/iu.test(value)) return "Q"; + return null; +} + +function talentLevel(value: string): string | undefined { + const match = /(?:LV\.?\s*)?([1-9]|10)/iu.exec(value); + return match?.[1]; +} + +function parseFullInfo( + matrix: SheetMatrix, + draft: GuideImportDraft, + catalog: ImportCatalog, +): void { + for (const row of matrix) { + for (const [labelColumn, metricStart, metricEnd] of [ + [4, 20, 30], + [34, 50, 60], + ] as const) { + const metrics = row + .slice(metricStart, metricEnd) + .filter((cell) => percent(cell) !== null); + addWeapon( + draft, + catalog, + row[labelColumn] ?? "", + metrics[0] ?? "", + metrics[1] ?? "", + "overall-personal", + ); + } + } + let early = false; + for (const row of matrix) { + if (row.some((cell) => /ต้นเกม/u.test(cell))) early = true; + addArtifact(draft, catalog, early ? 1 : 0, row[4] ?? "", row[16] ?? ""); + } + for (const row of matrix) { + const slots = [22, 28, 34] as const; + for (const [index, column] of slots.entries()) { + const value = mainStat(row[column] ?? ""); + if (!value) continue; + const key = (["sands", "goblet", "circlet"] as const)[index]; + if (validSlotStat(key, value)) draft.artifacts[key].push(value); + } + const target = targetName(row[22] ?? ""); + if (target && row[28]?.trim()) + draft.artifacts.targets.push({ stat: target, value: row[28].trim() }); + } + const allCells = matrix.flat(); + const substatCell = allCells.find( + (cell) => />/u.test(cell) && mainStat(cell), + ); + if (substatCell) + draft.artifacts.substats = unique( + substatCell + .split(/\s*>\s*/u) + .map(mainStat) + .filter((item): item is string => Boolean(item)), + ); + for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) { + for ( + let column = 0; + column < (matrix[rowIndex]?.length ?? 0); + column += 1 + ) { + const type = talentType(matrix[rowIndex][column]); + if (!type) continue; + const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? ""); + draft.artifacts.talents.push({ + type, + priority: draft.artifacts.talents.length + 1, + ...(level ? { level } : {}), + }); + } + } +} + +function parseCompactInfo( + matrix: SheetMatrix, + draft: GuideImportDraft, + catalog: ImportCatalog, +): void { + const metric = compactWeaponMetric(matrix); + const artifactStart = matrix.findIndex((row) => + row.some((cell) => /Artifacts\s+อาร์ติแฟกต์/iu.test(cell)), + ); + for (const row of matrix.slice( + 0, + artifactStart >= 0 ? artifactStart : undefined, + )) { + addWeapon( + draft, + catalog, + row[11] ?? "", + row[13] ?? "", + row[14] ?? "", + metric, + ); + addWeapon( + draft, + catalog, + row[17] ?? "", + row[19] ?? "", + row[20] ?? "", + metric, + ); + } + if (artifactStart >= 0) { + const extraRows: GuideImportDraft["extras"][number]["rows"] = []; + for (const row of matrix.slice(artifactStart)) { + addArtifact(draft, catalog, 0, row[11] ?? "", row[13] ?? ""); + addArtifact(draft, catalog, 1, row[17] ?? "", row[19] ?? ""); + for (const [labelColumn, detailColumn] of [ + [11, 13], + [17, 19], + ] as const) { + const label = cleanLabel(row[labelColumn] ?? ""); + const detail = row[detailColumn]?.trim() ?? ""; + if ( + label && + detail && + !/ชื่ออาร์ติแฟกต์/u.test(label) && + percent(detail) === null + ) { + extraRows.push({ + heading: label.slice(0, 200), + body: detail.slice(0, 2_000), + percent: null, + }); + } + } + } + if (extraRows.length) + draft.extras.push({ + title: "คำแนะนำอาร์ติแฟกต์เพิ่มเติม", + rows: extraRows.slice(0, 50), + }); + } + + const targetGroups: Array<{ + descriptor: string; + rows: Array<{ stat: string; value: string }>; + }> = []; + let current: { + descriptor: string; + rows: Array<{ stat: string; value: string }>; + } | null = null; + for (const row of matrix) { + const stat = targetName(row[5] ?? ""); + const value = row[6]?.trim(); + if (!stat || !value) continue; + if (!current || current.rows.some((item) => item.stat === stat)) { + current = { descriptor: row[7]?.trim() ?? "", rows: [] }; + targetGroups.push(current); + } + if (row[7]?.trim()) current.descriptor = row[7].trim(); + current.rows.push({ stat, value }); + } + const primary = + targetGroups.find((group) => /เลทเกม|AR\s*45/iu.test(group.descriptor)) ?? + targetGroups.at(-1); + if (primary) draft.artifacts.targets = primary.rows; + const alternateRows = targetGroups + .filter((group) => group !== primary) + .map((group, index) => ({ + heading: group.descriptor || `สแตททางเลือก ${index + 1}`, + body: group.rows.map((item) => `${item.stat}: ${item.value}`).join("\n"), + percent: null, + })); + if (alternateRows.length) + draft.extras.push({ title: "สแตทสำหรับแนวทางอื่น", rows: alternateRows }); + + for (let rowIndex = 0; rowIndex < matrix.length; rowIndex += 1) { + const row = matrix[rowIndex]; + const bestIndex = row.findIndex((cell) => + /ออฟหลักที่ดีที่สุด/iu.test(cell), + ); + if (bestIndex >= 0) { + const values = row + .slice(bestIndex + 1, bestIndex + 5) + .map(mainStat) + .filter((item): item is string => Boolean(item)); + if (values[0] && validSlotStat("sands", values[0])) + draft.artifacts.sands.push(values[0]); + if (values[1] && validSlotStat("goblet", values[1])) + draft.artifacts.goblet.push(values[1]); + if (values[2] && validSlotStat("circlet", values[2])) + draft.artifacts.circlet.push(values[2]); + } + const offIndex = row.findIndex((cell) => /ออฟรอง/u.test(cell)); + if (offIndex >= 0) { + const value = row.slice(offIndex + 1).find((cell) => />/u.test(cell)); + if (value) + draft.artifacts.substats = unique( + value + .split(/\s*>\s*/u) + .map(mainStat) + .filter((item): item is string => Boolean(item)), + ); + } + for (let column = 0; column < row.length; column += 1) { + const type = talentType(row[column]); + if (!type) continue; + const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? ""); + draft.artifacts.talents.push({ + type, + priority: draft.artifacts.talents.length + 1, + ...(level ? { level } : {}), + }); + } + } + + const constellationStart = matrix.findIndex((row) => + row.some((cell) => /Constellation\s+กลุ่มดาว/iu.test(cell)), + ); + const talentStart = matrix.findIndex( + (row, index) => + index > constellationStart && + row.some((cell) => /Talent\s+พรสวรรค์/iu.test(cell)), + ); + if (constellationStart >= 0) { + const rows: GuideImportDraft["constellationGroups"][number]["rows"] = []; + const character = catalog.characters.find( + (item) => item.key === draft.characterKey, + )!; + for (const row of matrix.slice( + constellationStart + 1, + talentStart > 0 ? talentStart : undefined, + )) { + const levelCell = row.find((cell) => /^C[0-6]$/iu.test(cell.trim())); + const explicitLevel = levelCell + ? Number(/\d/u.exec(levelCell)?.[0]) + : null; + if (explicitLevel === 0) continue; + const metrics = row + .map(percent) + .filter((item): item is number => item !== null); + if (!metrics.length || rows.length >= 6) continue; + const level = explicitLevel ?? rows.length + 1; + if (level < 1 || level > 6 || rows.some((item) => item.level === level)) + continue; + const previous = rows.at(-1)?.cumulativePercent ?? 100; + rows.push({ + level, + detail: + character.constellations.find((item) => item.level === level) + ?.description || `C${level}`, + cumulativePercent: metrics[0], + isGreat: metrics[0] - previous >= 10, + }); + } + if (rows.length) + draft.constellationGroups.push({ note: "C0 = 100%", rows }); + } +} + +function parseMember( + value: string, + catalog: ImportCatalog, +): { characterKey: string; constellation: number; refinement: number } | null { + const character = resolveCharacter(value, catalog.characters); + if (!character) return null; + return { + characterKey: character.key, + constellation: Number(/\bC([0-6])\b/iu.exec(value)?.[1] ?? 0), + refinement: refinement(value), + }; +} + +export function charactersFromCombo( + value: string, + catalog: ImportCatalog, +): ImportCatalogCharacter[] { + const found: ImportCatalogCharacter[] = []; + for (const segment of value.split(/\s*>\s*/u)) { + const cleaned = segment.trim(); + const matches = catalog.characters.filter((item) => { + const full = item.name.toLowerCase(); + const first = full.split(/\s+/u)[0]; + const source = cleaned.toLowerCase(); + return ( + source.startsWith(full) || + (first.length >= 3 && source.startsWith(first)) + ); + }); + if ( + matches.length === 1 && + !found.some((item) => item.key === matches[0].key) + ) + found.push(matches[0]); + } + return found; +} + +function parseTeams( + matrix: SheetMatrix, + draft: GuideImportDraft, + catalog: ImportCatalog, + sheetName: string, +): void { + for (let rowIndex = 0; rowIndex < matrix.length - 4; rowIndex += 1) { + const row = matrix[rowIndex]; + for (let start = 0; start < row.length; start += 1) { + const labels = [0, 3, 6, 9].map((offset) => row[start + offset] ?? ""); + const firstMember = parseMember(labels[0], catalog); + if (!firstMember) continue; + const damageRow = matrix[rowIndex + 1] ?? []; + const damages = [0, 3, 6, 9].map((offset) => + numeric(damageRow[start + offset] ?? ""), + ); + if (damages.some((damage) => damage === null)) continue; + const title = + matrix[rowIndex - 1]?.[start]?.trim() || + `${draft.source.characterName} Team`; + const rotationRow = matrix + .slice(rowIndex + 2, rowIndex + 5) + .find((candidate) => /TOTAL DPS/iu.test(candidate[start] ?? "")); + let rotation: number | null = rotationRow + ? Number(/\(([\d.]+)s\)/iu.exec(rotationRow[start] ?? "")?.[1]) + : null; + const rotationIndex = rotationRow ? matrix.indexOf(rotationRow) : -1; + const combo = + rotationIndex >= 0 + ? (matrix[rotationIndex + 1]?.[start]?.trim() ?? "") + : ""; + if ((!Number.isFinite(rotation) || rotation <= 0) && rotationRow) { + const dps = rotationRow + .slice(start, start + 13) + .map(numeric) + .find((item): item is number => item !== null && item > 0); + if (dps) + rotation = + damages + .map((damage) => damage ?? 0) + .reduce((total, damage) => total + damage, 0) / dps; + } + if (!Number.isFinite(rotation) || rotation <= 0) rotation = null; + const explicitMembers = labels + .map((label) => parseMember(label, catalog)) + .filter((member): member is NonNullable => + Boolean(member), + ); + const comboCharacters = charactersFromCombo(combo, catalog); + const members = [...explicitMembers]; + for (const character of comboCharacters) { + if (!members.some((member) => member.characterKey === character.key)) { + members.push({ + characterKey: character.key, + constellation: 0, + refinement: 1, + }); + } + } + if (members.length !== 4) { + draft.skipped.push( + `${sheetName}: team ${title} has ${members.length} resolvable members`, + ); + continue; + } + draft.teams.push({ + title: title.slice(0, 160), + rotationSeconds: rotation, + combo: (combo || "-").slice(0, 2_000), + members: members.map((member, index) => ({ + ...member, + weaponKey: null, + artifactKey: null, + secondArtifactKey: null, + damage: damages[index]!, + })), + }); + start += 9; + } + } +} + +function parseDetailedTeamExtra( + matrix: SheetMatrix, + sheetName: string, +): GuideImportDraft["extras"][number] | null { + const rows: GuideImportDraft["extras"][number]["rows"] = []; + for (let rowIndex = 0; rowIndex < matrix.length - 6; rowIndex += 1) { + const header = matrix[rowIndex]; + for (let start = 0; start < header.length; start += 1) { + if (!/ชื่อตัวละคร/u.test(header[start] ?? "")) continue; + const title = matrix[rowIndex - 1]?.[start]?.trim(); + if (!title) continue; + const lines: string[] = []; + for ( + let memberRow = rowIndex + 1; + memberRow <= rowIndex + 4; + memberRow += 1 + ) { + const row = matrix[memberRow] ?? []; + const character = row[start + 1]?.trim(); + const damage = row[start + 2]?.trim(); + if (!character || numeric(damage) === null) continue; + const weapon = row[start + 3]?.trim(); + const artifact = row[start + 4]?.trim(); + lines.push( + `${character}: ${damage}${weapon ? ` | ${weapon}` : ""}${artifact ? ` | ${artifact}` : ""}`, + ); + } + if (lines.length === 4) + rows.push({ + heading: title.slice(0, 200), + body: lines.join("\n").slice(0, 2_000), + percent: null, + }); + } + } + return rows.length + ? { + title: `${sheetName} — ข้อมูลทีม`.slice(0, 160), + rows: rows.slice(0, 50), + } + : null; +} + +function parseExtra( + matrix: SheetMatrix, + title: string, +): GuideImportDraft["extras"][number] | null { + const rows: GuideImportDraft["extras"][number]["rows"] = []; + for (const row of matrix) { + const values = row.map((cell) => cell.trim()).filter(Boolean); + if (!values.length) continue; + const metricIndex = values.findIndex((cell) => percent(cell) !== null); + const heading = values[0]; + if (/^(?:MVs%|DPS%|ความหมาย)$/iu.test(heading)) continue; + const body = values + .filter((_, index) => index !== metricIndex) + .slice(1) + .join(" — "); + rows.push({ + heading: heading.slice(0, 200), + body: body.slice(0, 2_000), + percent: metricIndex >= 0 ? percent(values[metricIndex]) : null, + }); + if (rows.length === 50) break; + } + return rows.length ? { title: title.slice(0, 160), rows } : null; +} + +export async function buildGuideImportDraft( + source: GuideImportSource, + catalog: ImportCatalog, +): Promise { + const character = resolveCharacter(source.characterName, catalog.characters); + if (!character) + throw new Error(`${source.characterName}: character not in catalog`); + const draft = emptyDraft(source, character.key); + if (source.skipWrite) return draft; + + if (source.layout === "full") { + const [weapons, artifacts, constellations] = await Promise.all([ + fetchSheetCsv(source, "Weapons (อาวุธ)", "A1:BK80"), + fetchSheetCsv(source, "Artifacts & Stats", "A1:BK80"), + fetchSheetCsv(source, "Constellations (กลุ่มดาว)", "A1:BK40"), + ]); + parseFullInfo(weapons, draft, catalog); + const artifactDraft = emptyDraft(source, character.key); + parseFullInfo(artifacts, artifactDraft, catalog); + draft.artifacts = artifactDraft.artifacts; + draft.skipped.push( + ...artifactDraft.skipped.filter((item) => item.startsWith("artifact")), + ); + const rows: GuideImportDraft["constellationGroups"][number]["rows"] = []; + for (const row of constellations) { + const value = percent(row[1] ?? ""); + if (value === null || rows.length >= 6) continue; + const level = rows.length + 1; + const previous = rows.at(-1)?.cumulativePercent ?? 100; + rows.push({ + level, + detail: + character.constellations.find((item) => item.level === level) + ?.description || + row[5] || + `C${level}`, + cumulativePercent: value, + isGreat: value - previous >= 10, + }); + } + if (rows.length) + draft.constellationGroups.push({ note: "C0 = 100%", rows }); + } else { + const info = await fetchSheetCsv(source, source.infoSheet, "A1:U100"); + parseCompactInfo(info, draft, catalog); + } + + for (const sheet of source.teamSheets) { + const matrix = await fetchSheetCsv(source, sheet, "A1:AN160"); + parseTeams(matrix, draft, catalog, sheet); + const detailed = parseDetailedTeamExtra(matrix, sheet); + if (detailed) draft.extras.push(detailed); + } + for (const sheet of source.comboSheets) { + const matrix = await fetchSheetCsv(source, sheet, "A1:BU100"); + const extra = parseExtra(matrix, sheet); + if (extra) draft.extras.push(extra); + } + + draft.artifacts.sands = unique(draft.artifacts.sands).slice(0, 3); + draft.artifacts.goblet = unique(draft.artifacts.goblet).slice(0, 3); + draft.artifacts.circlet = unique(draft.artifacts.circlet).slice(0, 3); + draft.artifacts.substats = unique(draft.artifacts.substats); + draft.artifacts.targets = draft.artifacts.targets.filter( + (target, index, rows) => + rows.findIndex((item) => item.stat === target.stat) === index, + ); + draft.artifacts.talents = draft.artifacts.talents + .filter( + (talent, index, rows) => + rows.findIndex((item) => item.type === talent.type) === index, + ) + .slice(0, 3) + .map((talent, index) => ({ ...talent, priority: index + 1 })); + draft.weapons = draft.weapons.slice(0, 100); + draft.teams = draft.teams.slice(0, 30); + draft.extras = draft.extras.slice(0, 20); + return draft; +} diff --git a/package.json b/package.json index 9f16bed..56a2173 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:migrate": "bun scripts/migrate.ts", "db:studio": "drizzle-kit studio", "db:seed": "bun scripts/seed-templates.ts", + "import:guides": "bun scripts/import-google-guides.ts", "worker:discord": "bun backend/discord.ts", "worker:outbox": "bun scripts/outbox-worker.ts" }, diff --git a/scripts/import-google-guides.ts b/scripts/import-google-guides.ts new file mode 100644 index 0000000..76bc12b --- /dev/null +++ b/scripts/import-google-guides.ts @@ -0,0 +1,315 @@ +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; + +import { closeDb, getDb } from "@/db/client"; +import { + artifactGroups, + artifactProfiles, + artifactRecommendations, + catalogArtifacts, + catalogCharacters, + catalogWeapons, + constellationConditions, + constellationRecommendations, + extraSectionRows, + extraSections, + guides, + guideSections, + media, + outboxEvents, + teamCompositions, + teamMembers, + weaponRecommendations, +} from "@/db/schema"; +import { + artifactSectionSchema, + constellationSectionSchema, + extraSectionSchema, + slugFromCharacterName, + teamSectionSchema, + weaponSectionSchema, +} from "@/lib/guides/schemas"; +import { + buildGuideImportDraft, + guideImportSources, + type GuideImportDraft, + type ImportCatalog, +} from "@/lib/guides/google-sheet-import"; +import { getMediaStorage } from "@/lib/media/storage"; +import { hasValidImageSignature, MAX_MEDIA_BYTES } from "@/lib/media/validation"; + +const apply = process.argv.includes("--apply"); + +interface CoverAsset { + bytes: Uint8Array; + extension: "gif" | "jpeg" | "png" | "webp"; + mimeType: "image/gif" | "image/jpeg" | "image/png" | "image/webp"; + objectKey: string; +} + +function coverType(bytes: Uint8Array): Pick | null { + const types = [ + ["png", "image/png"], + ["jpeg", "image/jpeg"], + ["webp", "image/webp"], + ["gif", "image/gif"], + ] as const; + for (const [extension, mimeType] of types) { + if (hasValidImageSignature(bytes.subarray(0, 16), mimeType)) return { extension, mimeType }; + } + return null; +} + +async function downloadCover(draft: GuideImportDraft): Promise { + const response = await fetch(`https://buzz.sudloh.com/cdn/${draft.source.coverId}`, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`${draft.source.characterName}: cover returned ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw new Error(`${draft.source.characterName}: invalid cover size ${bytes.length}`); + const type = coverType(bytes); + if (!type) throw new Error(`${draft.source.characterName}: unsupported cover image`); + return { + ...type, + bytes, + objectKey: `imports/buzz-guide/${draft.characterKey}/cover.${type.extension}`, + }; +} + +function validateDraft(draft: GuideImportDraft): void { + weaponSectionSchema.parse({ expectedVersion: 1, note: null, rows: draft.weapons }); + artifactSectionSchema.parse({ expectedVersion: 1, ...draft.artifacts }); + constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: draft.constellationGroups }); + teamSectionSchema.parse({ expectedVersion: 1, teams: draft.teams }); + extraSectionSchema.parse({ + expectedVersion: 1, + sections: draft.extras.map((section) => ({ + title: section.title, + rows: section.rows.map((row) => ({ ...row, mediaId: null, ratio: "16:9" as const })), + })), + }); +} + +function reportFor(draft: GuideImportDraft) { + return { + character: draft.source.characterName, + action: draft.source.skipWrite ? "keep-existing" : draft.source.characterName === "Columbina" ? "restore-or-update" : "create-or-update", + weapons: draft.weapons.length, + artifacts: draft.artifacts.groups.reduce((total, group) => total + group.rows.length, 0), + constellations: draft.constellationGroups.reduce((total, group) => total + group.rows.length, 0), + teams: draft.teams.length, + extras: draft.extras.reduce((total, section) => total + section.rows.length, 0), + skipped: [...new Set(draft.skipped)], + }; +} + +async function loadCatalog(): Promise { + const db = getDb(); + const [characters, weapons, artifacts] = await Promise.all([ + db.select({ key: catalogCharacters.key, name: catalogCharacters.name, constellations: catalogCharacters.constellations }).from(catalogCharacters), + db.select({ key: catalogWeapons.key, name: catalogWeapons.name }).from(catalogWeapons), + db.select({ key: catalogArtifacts.key, name: catalogArtifacts.name }).from(catalogArtifacts), + ]); + return { characters, weapons, artifacts }; +} + +async function uniqueSlug(tx: Parameters["transaction"]>[0]>[0], name: string, key: string): Promise { + const base = slugFromCharacterName(name, key); + const [collision] = await tx.select({ id: guides.id }).from(guides).where(eq(guides.slug, base)).limit(1); + return collision ? `${base}-${key.toLowerCase()}` : base; +} + +async function replaceGuide( + tx: Parameters["transaction"]>[0]>[0], + draft: GuideImportDraft, + coverMediaId: string, + sortOrder: number, +): Promise { + const [catalogCharacter] = await tx.select().from(catalogCharacters).where(eq(catalogCharacters.key, draft.characterKey)).limit(1); + if (!catalogCharacter) throw new Error(`${draft.source.characterName}: character disappeared from catalog`); + const [existing] = await tx.select().from(guides).where(eq(guides.characterKey, draft.characterKey)).limit(1); + let guideId: string; + let version: number; + if (existing) { + guideId = existing.id; + version = existing.version + 1; + await tx.update(guides).set({ + overview: draft.source.overview, + coverMediaId, + sortOrder, + trashedAt: null, + version, + }).where(eq(guides.id, guideId)); + if (existing.coverMediaId !== coverMediaId) { + await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, existing.coverMediaId)); + await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId)); + } + } else { + const slug = await uniqueSlug(tx, catalogCharacter.name, catalogCharacter.key); + const [created] = await tx.insert(guides).values({ + characterKey: catalogCharacter.key, + slug, + name: catalogCharacter.name, + overview: draft.source.overview, + coverMediaId, + sortOrder, + }).returning(); + guideId = created.id; + version = created.version; + await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId)); + } + + await Promise.all([ + tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)), + tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)), + tx.delete(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)), + tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)), + tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)), + tx.delete(guideSections).where(eq(guideSections.guideId, guideId)), + ]); + + const defaultSections = [ + { kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 }, + { kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 }, + { kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 }, + { kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 }, + ]; + await tx.insert(guideSections).values(defaultSections.map((section) => ({ ...section, guideId }))); + + if (draft.weapons.length) { + await tx.insert(weaponRecommendations).values(draft.weapons.map((row, rowIndex) => ({ + ...row, + guideId, + sortOrder: rowIndex, + overallPercent: row.overallPercent?.toString() ?? null, + personalPercent: row.personalPercent?.toString() ?? null, + }))); + } + for (const [groupIndex, group] of draft.artifacts.groups.entries()) { + const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder: groupIndex }).returning(); + if (group.rows.length) { + await tx.insert(artifactRecommendations).values(group.rows.map((row, rowIndex) => ({ + ...row, + groupId: created.id, + sortOrder: rowIndex, + percent: row.percent.toString(), + }))); + } + } + await tx.insert(artifactProfiles).values({ + guideId, + sands: draft.artifacts.sands, + goblet: draft.artifacts.goblet, + circlet: draft.artifacts.circlet, + substats: draft.artifacts.substats, + targets: draft.artifacts.targets, + talents: draft.artifacts.talents, + note: draft.artifacts.note, + }); + for (const [groupIndex, group] of draft.constellationGroups.entries()) { + const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder: groupIndex }).returning(); + if (group.rows.length) { + await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({ + ...row, + conditionId: created.id, + cumulativePercent: row.cumulativePercent.toString(), + }))); + } + } + for (const [teamIndex, team] of draft.teams.entries()) { + const [created] = await tx.insert(teamCompositions).values({ + guideId, + title: team.title, + rotationSeconds: team.rotationSeconds?.toString() ?? null, + combo: team.combo, + sortOrder: teamIndex, + }).returning(); + await tx.insert(teamMembers).values(team.members.map((member, position) => ({ + ...member, + teamId: created.id, + position, + damage: member.damage.toString(), + }))); + } + for (const [extraIndex, section] of draft.extras.entries()) { + const [createdSection] = await tx.insert(guideSections).values({ + guideId, + kind: "extra", + slug: `extra-${extraIndex + 1}`, + title: section.title, + sortOrder: extraIndex + 4, + }).returning(); + await tx.insert(extraSections).values({ sectionId: createdSection.id }); + await tx.insert(extraSectionRows).values(section.rows.map((row, rowIndex) => ({ + sectionId: createdSection.id, + heading: row.heading, + body: row.body, + percent: row.percent?.toString() ?? null, + ratio: "16:9", + sortOrder: rowIndex, + }))); + } + + await tx.insert(outboxEvents).values([ + { topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } }, + { topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } }, + { topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } }, + ]); +} + +async function applyDrafts(drafts: GuideImportDraft[]): Promise { + const writable = drafts.filter((draft) => !draft.source.skipWrite); + const covers = new Map(); + for (const draft of writable) covers.set(draft.characterKey, await downloadCover(draft)); + const storage = await getMediaStorage(); + for (const cover of covers.values()) { + await storage.write(cover.objectKey, cover.bytes, { type: cover.mimeType, acl: "public-read" }); + } + + const db = getDb(); + const [{ maximum }] = await db.select({ maximum: sql`coalesce(max(${guides.sortOrder}), 0)` }).from(guides).where(isNull(guides.trashedAt)); + await db.transaction(async (tx) => { + for (const [index, draft] of writable.entries()) { + const cover = covers.get(draft.characterKey)!; + let [coverRecord] = await tx.select().from(media).where(eq(media.objectKey, cover.objectKey)).limit(1); + if (!coverRecord) { + [coverRecord] = await tx.insert(media).values({ + objectKey: cover.objectKey, + fileName: `cover.${cover.extension}`, + mimeType: cover.mimeType, + byteSize: cover.bytes.length, + status: "ready", + }).returning(); + } else { + [coverRecord] = await tx.update(media).set({ + mimeType: cover.mimeType, + byteSize: cover.bytes.length, + status: "ready", + }).where(eq(media.id, coverRecord.id)).returning(); + } + await replaceGuide(tx, draft, coverRecord.id, Number(maximum) + index + 1); + } + }); +} + +async function main(): Promise { + const catalog = await loadCatalog(); + const drafts: GuideImportDraft[] = []; + for (const source of guideImportSources) { + const draft = await buildGuideImportDraft(source, catalog); + validateDraft(draft); + drafts.push(draft); + } + console.log(JSON.stringify({ mode: apply ? "apply" : "dry-run", guides: drafts.map(reportFor) }, null, 2)); + if (!apply) return; + await applyDrafts(drafts); + const sourceKeys = drafts.map((draft) => draft.characterKey); + const active = await getDb().select({ characterKey: guides.characterKey }).from(guides).where(and(inArray(guides.characterKey, sourceKeys), isNull(guides.trashedAt))); + if (active.length !== sourceKeys.length) throw new Error(`Expected ${sourceKeys.length} active source guides, found ${active.length}`); + console.log(`Imported ${drafts.filter((draft) => !draft.source.skipWrite).length} guides; ${active.length} source guides are active.`); +} + +try { + await main(); +} finally { + await closeDb(); +}