From 0b16f5aa84676afbfd32d9cd24ee89251787af51 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 3 Sep 2026 21:46:35 +0700 Subject: [PATCH] feat : remove --- lib/guides/google-sheet-import.test.ts | 95 -- lib/guides/google-sheet-import.ts | 1388 ------------------------ scripts/import-google-guides.ts | 315 ------ 3 files changed, 1798 deletions(-) delete mode 100644 lib/guides/google-sheet-import.test.ts delete mode 100644 lib/guides/google-sheet-import.ts delete mode 100644 scripts/import-google-guides.ts diff --git a/lib/guides/google-sheet-import.test.ts b/lib/guides/google-sheet-import.test.ts deleted file mode 100644 index 3abf680..0000000 --- a/lib/guides/google-sheet-import.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 7707849..0000000 --- a/lib/guides/google-sheet-import.ts +++ /dev/null @@ -1,1388 +0,0 @@ -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/scripts/import-google-guides.ts b/scripts/import-google-guides.ts deleted file mode 100644 index 76bc12b..0000000 --- a/scripts/import-google-guides.ts +++ /dev/null @@ -1,315 +0,0 @@ -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(); -}