diff --git a/package-lock.json b/package-lock.json index 4946794..19f832c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "dotenv": "^17.4.2", "ecpair": "^3.0.0", "embla-carousel-react": "^8.3.0", + "fflate": "^0.8.3", "hls.js": "^1.6.15", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", @@ -120,7 +121,6 @@ "tinyld": "^1.3.4", "vaul": "^0.9.3", "websocket-ts": "^2.2.1", - "xlsx": "^0.18.5", "zod": "^4.6.7" }, "devDependencies": { @@ -6006,15 +6006,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adler-32": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", - "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -7048,19 +7039,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/cfb": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", - "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -7207,15 +7185,6 @@ "typescript": "^5" } }, - "node_modules/codepage": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", - "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7329,18 +7298,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8615,15 +8572,6 @@ "node": ">= 0.6" } }, - "node_modules/frac": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", - "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -12266,18 +12214,6 @@ "node": ">= 10.x" } }, - "node_modules/ssf": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", - "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", - "license": "Apache-2.0", - "dependencies": { - "frac": "~1.1.2" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -14127,24 +14063,6 @@ "bs58check": "^4.0.0" } }, - "node_modules/wmf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", - "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/word": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", - "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14246,27 +14164,6 @@ "node": ">=14.17.0" } }, - "node_modules/xlsx": { - "version": "0.18.5", - "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", - "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", - "license": "Apache-2.0", - "dependencies": { - "adler-32": "~1.3.0", - "cfb": "~1.2.1", - "codepage": "~1.15.0", - "crc-32": "~1.2.1", - "ssf": "~0.11.2", - "wmf": "~1.0.1", - "word": "~0.3.0" - }, - "bin": { - "xlsx": "bin/xlsx.njs" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index e58ab2a..a09d6cd 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "dotenv": "^17.4.2", "ecpair": "^3.0.0", "embla-carousel-react": "^8.3.0", + "fflate": "^0.8.3", "hls.js": "^1.6.15", "html2canvas": "^1.4.1", "html5-qrcode": "^2.3.8", @@ -142,7 +143,6 @@ "tinyld": "^1.3.4", "vaul": "^0.9.3", "websocket-ts": "^2.2.1", - "xlsx": "^0.18.5", "zod": "^4.6.7" }, "devDependencies": { diff --git a/src/components/DeckCostCalculator.tsx b/src/components/DeckCostCalculator.tsx index ca0b5f8..c4147c0 100644 --- a/src/components/DeckCostCalculator.tsx +++ b/src/components/DeckCostCalculator.tsx @@ -1002,10 +1002,10 @@ export function DeckCostCalculator() { a.click(); }; - // Multi-sheet XLSX workbook (lazy — SheetJS is only loaded on demand). + // Multi-sheet XLSX workbook (lazy — our tiny local OOXML writer is only + // loaded when the user asks for the spreadsheet). const exportXlsx = async () => { - const XLSX = await import('xlsx'); - const wb = XLSX.utils.book_new(); + const { downloadXlsx } = await import('@/lib/xlsx'); const num = (v: number) => Number(v.toFixed(4)); const summary: (string | number)[][] = [ [t('legacyMiscUi.exportTitle')], @@ -1024,24 +1024,29 @@ export function DeckCostCalculator() { [t('legacyMiscUi.exportKvProcessesPerBoard'), num(r.processes)], [t('legacyMiscUi.exportKvPackagingPerBoard'), num(r.packaging)], ]; - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), t('legacyMiscUi.exportXlsxSummary')); - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ + const equipment: (string | number)[][] = [ [t('legacyMiscUi.exportSectionEquipment'), t('legacyMiscUi.exportColCost'), t('legacyMiscUi.exportColSetupH'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], ...s.equipment.map((e) => [e.name, e.cost, e.setupHrs, e.boards, num(e.boards > 0 ? (e.cost + e.setupHrs * r.laborPerHr) / e.boards : 0)]), - ]), t('legacyMiscUi.exportSectionEquipment')); - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ + ]; + const materials: (string | number)[][] = [ [t('legacyMiscUi.exportColMaterial'), t('legacyMiscUi.exportColOrderCost'), t('legacyMiscUi.exportColShipping'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColOrderMin'), t('legacyMiscUi.exportColUnloadMin'), t('legacyMiscUi.exportColPerBoard')], ...s.materials.map((m) => [m.name, m.orderCost, m.shipping, m.boards, m.orderMin, m.unloadMin, num(m.boards > 0 ? (m.orderCost + m.shipping + (m.orderMin + m.unloadMin) * r.laborPerMin) / m.boards : 0)]), - ]), t('legacyMiscUi.exportSectionMaterials')); - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ + ]; + const processes: (string | number)[][] = [ [t('legacyMiscUi.exportColProcess'), t('legacyMiscUi.exportColTimeMin'), t('legacyMiscUi.exportColBoards'), t('legacyMiscUi.exportColPerBoard')], ...s.processes.map((p) => [p.name, p.minutes, p.boards, p.rate, p.scrapPct, num(p.boards > 0 ? (p.minutes * ((p.rate > 0 ? p.rate : r.laborPerHr) / 60)) / p.boards : 0)]), - ]), t('legacyMiscUi.exportLabelProcesses')); - XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([ + ]; + const packaging: (string | number)[][] = [ [t('legacyMiscUi.exportSectionPackaging'), t('legacyMiscUi.exportColTimeBoardMin'), t('legacyMiscUi.exportColMaterialsBoard'), t('legacyMiscUi.exportColPerBoard')], ...s.packaging.map((p) => [p.name, p.minutesPerBoard, p.materialsPerBoard, num(p.materialsPerBoard + p.minutesPerBoard * r.laborPerMin)]), - ]), t('legacyMiscUi.exportSectionPackaging')); - XLSX.writeFile(wb, `deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`); + ]; + downloadXlsx(`deck-cost-${new Date().toISOString().slice(0, 10)}.xlsx`, [ + { name: t('legacyMiscUi.exportXlsxSummary'), rows: summary }, + { name: t('legacyMiscUi.exportSectionEquipment'), rows: equipment }, + { name: t('legacyMiscUi.exportSectionMaterials'), rows: materials }, + { name: t('legacyMiscUi.exportLabelProcesses'), rows: processes }, + { name: t('legacyMiscUi.exportSectionPackaging'), rows: packaging }, + ]); }; const copySummary = async () => { diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index 266b137..5008f38 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -216,3 +216,33 @@ export function useLabProfile(identifier: string, pubkey?: string) { refetchOnMount: 'always', }); } + +/** Lab provider profiles authored by one pubkey (profile-page services strip). */ +export function useUserLabProfiles(pubkey: string | undefined) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); + + return useQuery({ + queryKey: ['user-lab-profiles', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, + select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden), + queryFn: async () => { + if (!pubkey) return []; + const signal = AbortSignal.timeout(12_000); + const events = await querySkateContent( + [{ + kinds: LAB_KINDS, + authors: [pubkey], + '#t': LAB_SELF_TAGS, + limit: 50, + }], + { signal }, + ); + return events + .map(parseLabProfile) + .filter((profile): profile is LabProfile => !!profile) + .sort((a, b) => b.createdAt - a.createdAt); + }, + staleTime: 60_000, + }); +} diff --git a/src/i18n/ca/batch6.ts b/src/i18n/ca/batch6.ts index 6efe2d1..46d30db 100644 --- a/src/i18n/ca/batch6.ts +++ b/src/i18n/ca/batch6.ts @@ -986,6 +986,7 @@ const batch6 = { nip05NotVerified: "NIP-05 no verificat amb aquesta clau", message: "Missatge", eventsHosted: "Esdeveniments organitzats", + labServices: "Serveis de The Lab", openToSponsorships: "Obert a patrocinis", forSponsors: "Per a patrocinadors i col·laboracions", sponsorPitch: "Patrocini, col·laboració, foto/vídeo o esdeveniment? Comença una conversa.", diff --git a/src/i18n/ca/drift.ts b/src/i18n/ca/drift.ts index 1914bab..67b62e4 100644 --- a/src/i18n/ca/drift.ts +++ b/src/i18n/ca/drift.ts @@ -33,6 +33,7 @@ const drift = { }, labLink: { transport: 'Transport', + designer: 'Roba', }, detail: { noun: { diff --git a/src/i18n/cs/batch4.ts b/src/i18n/cs/batch4.ts index dff69ac..d04c93d 100644 --- a/src/i18n/cs/batch4.ts +++ b/src/i18n/cs/batch4.ts @@ -960,6 +960,7 @@ export const csBatch4 = { nip05NotVerified: "NIP-05 neověřeno vůči tomuto klíči", message: "Zpráva", eventsHosted: "Uspořádané akce", + labServices: "Lab služby", openToSponsorships: "Otevřeno sponzorům", forSponsors: "Pro sponzory a spolupráce", sponsorPitch: "Sponzorství, spolupráce, foto/video nebo akce? Začni konverzaci.", diff --git a/src/i18n/cs/drift.ts b/src/i18n/cs/drift.ts index 48a7648..35f84d5 100644 --- a/src/i18n/cs/drift.ts +++ b/src/i18n/cs/drift.ts @@ -75,6 +75,7 @@ const drift = { "offerTransport": "Nabídnout dopravu", "labLink": { "transport": "Doprava", + "designer": "Oblečení", }, "detail": { "noun": { diff --git a/src/i18n/de/batch4.ts b/src/i18n/de/batch4.ts index 153842e..5d6a4d7 100644 --- a/src/i18n/de/batch4.ts +++ b/src/i18n/de/batch4.ts @@ -960,6 +960,7 @@ const deBatch4 = { nip05NotVerified: "NIP-05 nicht gegen diesen Schlüssel verifiziert", message: "Nachricht", eventsHosted: "Organisierte Events", + labServices: "Lab-Dienste", openToSponsorships: "Offen für Sponsoring", forSponsors: "Für Sponsoren & Kooperationen", sponsorPitch: "Sponsoring, Kooperation, Foto/Video oder Event? Starte ein Gespräch.", diff --git a/src/i18n/de/drift.ts b/src/i18n/de/drift.ts index 172d475..b5ebc9a 100644 --- a/src/i18n/de/drift.ts +++ b/src/i18n/de/drift.ts @@ -77,6 +77,7 @@ const drift = { offerTransport: "Transport anbieten", labLink: { transport: "Transport", + designer: "Kleidung", }, detail: { noun: { diff --git a/src/i18n/el/batch4.ts b/src/i18n/el/batch4.ts index 7b0ea58..2833b40 100644 --- a/src/i18n/el/batch4.ts +++ b/src/i18n/el/batch4.ts @@ -960,6 +960,7 @@ const elBatch4 = { nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί", message: "Μήνυμα", eventsHosted: "Εκδηλώσεις που διοργάνωσες", + labServices: "Υπηρεσίες Lab", openToSponsorships: "Ανοιχτός σε χορηγίες", forSponsors: "Για χορηγούς και συνεργασίες", sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.", diff --git a/src/i18n/el/drift.ts b/src/i18n/el/drift.ts index 0f1377f..da69e7a 100644 --- a/src/i18n/el/drift.ts +++ b/src/i18n/el/drift.ts @@ -79,6 +79,7 @@ const drift = { offerTransport: "Πρόσφερε μεταφορές", labLink: { transport: "Μεταφορές", + designer: "Ρούχα", }, detail: { noun: { diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 39bf508..d5991b3 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -2691,6 +2691,7 @@ labUi: { barber: 'Barber', organizer: 'Organizer', transport: 'Transport', + designer: 'Clothing', lab: 'Lab', linkedProfiles: 'Linked Lab Profiles', desc: 'Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional. When the Lab profile also links back to this crew, both show a green', @@ -6793,6 +6794,7 @@ walletUi: { nip05NotVerified: 'NIP-05 not verified against this key', message: 'Message', eventsHosted: 'Events hosted', + labServices: 'Lab services', openToSponsorships: 'Open to sponsorships', forSponsors: 'For sponsors & collabs', sponsorPitch: 'Sponsorship, collab, photo/video or event? Start a conversation.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index b5ad3ca..2802009 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -2691,6 +2691,7 @@ labUi: { barber: 'Barbero', organizer: 'Organizador', transport: 'Transporte', + designer: 'Ropa', lab: 'Lab', linkedProfiles: 'Perfiles del Lab vinculados', desc: '¿También tienes un estudio, clases o un servicio de construcción? Vincula tus perfiles del Lab para que la gente pueda contratar a tu crew. Opcional. Cuando el perfil del Lab también enlaza de vuelta a este crew, ambos muestran una insignia', @@ -6793,6 +6794,7 @@ walletUi: { nip05NotVerified: 'NIP-05 no verificado con esta clave', message: 'Mensaje', eventsHosted: 'Eventos organizados', + labServices: 'Servicios de Lab', openToSponsorships: 'Abierto a patrocinios', forSponsors: 'Para patrocinadores y colaboraciones', sponsorPitch: '¿Patrocinio, colaboración, foto/vídeo o evento? Empieza una conversación.', diff --git a/src/i18n/eu/batch6.ts b/src/i18n/eu/batch6.ts index b76e3d3..b7088ed 100644 --- a/src/i18n/eu/batch6.ts +++ b/src/i18n/eu/batch6.ts @@ -986,6 +986,7 @@ const batch6 = { nip05NotVerified: "NIP-05 ez da gako honekin egiaztatu", message: "Mezua", eventsHosted: "Antolatutako ekitaldiak", + labServices: "Lab zerbitzuak", openToSponsorships: "Babesleentzako irekita", forSponsors: "Babesleentzat eta lankidetzetarako", sponsorPitch: "Babesa, lankidetza, argazki/bideoa edo ekitaldia? Hasi solasaldia.", diff --git a/src/i18n/eu/drift.ts b/src/i18n/eu/drift.ts index 4eb104c..9748f75 100644 --- a/src/i18n/eu/drift.ts +++ b/src/i18n/eu/drift.ts @@ -33,6 +33,7 @@ const drift = { }, labLink: { transport: 'Garraioa', + designer: 'Arropa', }, detail: { noun: { diff --git a/src/i18n/fr/batch4.ts b/src/i18n/fr/batch4.ts index e2cbd2b..e525dfe 100644 --- a/src/i18n/fr/batch4.ts +++ b/src/i18n/fr/batch4.ts @@ -960,6 +960,7 @@ const frBatch4 = { nip05NotVerified: "NIP-05 non vérifié pour cette clé", message: "Message", eventsHosted: "Événements organisés", + labServices: "Services Lab", openToSponsorships: "Ouvert au sponsoring", forSponsors: "Pour les sponsors et collaborations", sponsorPitch: "Sponsoring, collaboration, photo/vidéo ou événement ? Lance la conversation.", diff --git a/src/i18n/fr/drift.ts b/src/i18n/fr/drift.ts index 71ef294..c1b6c31 100644 --- a/src/i18n/fr/drift.ts +++ b/src/i18n/fr/drift.ts @@ -77,6 +77,7 @@ const drift = { offerTransport: "Proposer un transport", labLink: { transport: "Transport", + designer: "Vêtements", }, detail: { noun: { diff --git a/src/i18n/gl/batch6.ts b/src/i18n/gl/batch6.ts index 9411f08..4ed5101 100644 --- a/src/i18n/gl/batch6.ts +++ b/src/i18n/gl/batch6.ts @@ -986,6 +986,7 @@ const batch6 = { "nip05NotVerified": "NIP-05 non verificado con esta chave", "message": "Mensaxe", "eventsHosted": "Eventos organizados", + "labServices": "Servizos do Lab", "openToSponsorships": "Aberto a patrocinios", "forSponsors": "Para patrocinadores e colaboracións", "sponsorPitch": "Patrocinio, colaboración, foto/vídeo ou evento? Comeza unha conversa.", diff --git a/src/i18n/gl/drift.ts b/src/i18n/gl/drift.ts index 1a878b6..15f624e 100644 --- a/src/i18n/gl/drift.ts +++ b/src/i18n/gl/drift.ts @@ -33,6 +33,7 @@ const drift = { }, labLink: { transport: 'Transporte', + designer: 'Roupa', }, detail: { noun: { diff --git a/src/i18n/hi/batch6.ts b/src/i18n/hi/batch6.ts index 1ab9c22..6e14c46 100644 --- a/src/i18n/hi/batch6.ts +++ b/src/i18n/hi/batch6.ts @@ -986,6 +986,7 @@ const batch6 = { "nip05NotVerified": "NIP-05 इस कुंजी से सत्यापित नहीं", "message": "संदेश", "eventsHosted": "आयोजित कार्यक्रम", + "labServices": "Lab सेवाएँ", "openToSponsorships": "प्रायोजन के लिए उपलब्ध", "forSponsors": "प्रायोजकों और सहयोगों के लिए", "sponsorPitch": "प्रायोजन, सहयोग, फ़ोटो/वीडियो या कार्यक्रम? बातचीत शुरू करें।", diff --git a/src/i18n/hi/drift.ts b/src/i18n/hi/drift.ts index 54b7012..f9afd13 100644 --- a/src/i18n/hi/drift.ts +++ b/src/i18n/hi/drift.ts @@ -28,6 +28,7 @@ const drift = { }, labLink: { transport: 'परिवहन', + designer: 'कपड़े', }, detail: { noun: { diff --git a/src/i18n/id/batch4.ts b/src/i18n/id/batch4.ts index da52ffe..9ea1d7e 100644 --- a/src/i18n/id/batch4.ts +++ b/src/i18n/id/batch4.ts @@ -966,6 +966,7 @@ const idBatch4 = { nip05NotVerified: 'NIP-05 tidak terverifikasi terhadap kunci ini', message: 'Pesan', eventsHosted: 'Acara yang diselenggarakan', + labServices: 'Layanan Lab', openToSponsorships: 'Terbuka untuk sponsor', forSponsors: 'Untuk sponsor & kolaborasi', sponsorPitch: 'Sponsorship, kolaborasi, foto/video, atau acara? Mulai percakapan.', diff --git a/src/i18n/id/drift.ts b/src/i18n/id/drift.ts index d211a8f..47797fa 100644 --- a/src/i18n/id/drift.ts +++ b/src/i18n/id/drift.ts @@ -71,6 +71,7 @@ const driftStrings = { offerTransport: 'Tawarkan transportasi', labLink: { transport: 'Transportasi', + designer: 'Pakaian', }, detail: { noun: { diff --git a/src/i18n/it/batch4.ts b/src/i18n/it/batch4.ts index 9eb8caa..de245b9 100644 --- a/src/i18n/it/batch4.ts +++ b/src/i18n/it/batch4.ts @@ -960,6 +960,7 @@ const itBatch4 = { nip05NotVerified: "NIP-05 non verificato con questa chiave", message: "Messaggio", eventsHosted: "Eventi organizzati", + labServices: "Servizi Lab", openToSponsorships: "Aperto a sponsorizzazioni", forSponsors: "Per sponsor e collaborazioni", sponsorPitch: "Sponsorizzazione, collaborazione, foto/video o evento? Inizia una conversazione.", diff --git a/src/i18n/it/drift.ts b/src/i18n/it/drift.ts index d9d1712..0249364 100644 --- a/src/i18n/it/drift.ts +++ b/src/i18n/it/drift.ts @@ -77,6 +77,7 @@ const drift = { offerTransport: "Offri trasporto", labLink: { transport: "Trasporto", + designer: "Abbigliamento", }, detail: { noun: { diff --git a/src/i18n/ja/batch7.ts b/src/i18n/ja/batch7.ts index 6d84617..10a0416 100644 --- a/src/i18n/ja/batch7.ts +++ b/src/i18n/ja/batch7.ts @@ -969,6 +969,7 @@ const jaBatch7 = { nip05NotVerified: 'NIP-05がこの鍵で認証されていません', message: 'メッセージ', eventsHosted: '主催イベント', + labServices: 'Labサービス', openToSponsorships: 'スポンサー募集中', forSponsors: 'スポンサー・コラボ募集', sponsorPitch: 'スポンサー、コラボ、写真/動画、イベントの依頼はありませんか?まずはご相談ください。', diff --git a/src/i18n/ja/drift.ts b/src/i18n/ja/drift.ts index 7389817..7d092c1 100644 --- a/src/i18n/ja/drift.ts +++ b/src/i18n/ja/drift.ts @@ -87,6 +87,7 @@ const drift: Dict = { offerTransport: '輸送を提供', labLink: { transport: '輸送', + designer: '服', }, detail: { noun: { diff --git a/src/i18n/ko/batch4.ts b/src/i18n/ko/batch4.ts index ff75e3d..70a7784 100644 --- a/src/i18n/ko/batch4.ts +++ b/src/i18n/ko/batch4.ts @@ -968,6 +968,7 @@ const koBatch4 = { nip05NotVerified: '이 키에 대해 NIP-05가 인증되지 않음', message: '메시지', eventsHosted: '주최한 이벤트', + labServices: 'Lab 서비스', openToSponsorships: '스폰서 모집 중', forSponsors: '스폰서 및 협업 문의', sponsorPitch: '스폰서십, 협업, 사진/영상 또는 이벤트? 대화를 시작해 보세요.', diff --git a/src/i18n/ko/drift.ts b/src/i18n/ko/drift.ts index 8bfd71c..d4fb93a 100644 --- a/src/i18n/ko/drift.ts +++ b/src/i18n/ko/drift.ts @@ -87,6 +87,7 @@ const drift: Dict = { offerTransport: '운송 제공', labLink: { transport: '운송', + designer: '의류', }, detail: { noun: { diff --git a/src/i18n/nl/batch4.ts b/src/i18n/nl/batch4.ts index 3aad435..73712cd 100644 --- a/src/i18n/nl/batch4.ts +++ b/src/i18n/nl/batch4.ts @@ -968,6 +968,7 @@ const nlBatch4 = { nip05NotVerified: 'NIP-05 niet geverifieerd tegen deze sleutel', message: 'Bericht', eventsHosted: 'Georganiseerde evenementen', + labServices: 'Lab-diensten', openToSponsorships: 'Open voor sponsoring', forSponsors: 'Voor sponsors & samenwerkingen', sponsorPitch: 'Sponsoring, samenwerking, foto/video of evenement? Start een gesprek.', diff --git a/src/i18n/nl/drift.ts b/src/i18n/nl/drift.ts index 65377ee..fbc3b89 100644 --- a/src/i18n/nl/drift.ts +++ b/src/i18n/nl/drift.ts @@ -75,6 +75,7 @@ const drift = { "offerTransport": "Transport aanbieden", "labLink": { "transport": "Transport", + "designer": "Kleding", }, "detail": { "noun": { diff --git a/src/i18n/pl/batch4.ts b/src/i18n/pl/batch4.ts index 11ca5aa..b4a4cc3 100644 --- a/src/i18n/pl/batch4.ts +++ b/src/i18n/pl/batch4.ts @@ -960,6 +960,7 @@ const plBatch4 = { nip05NotVerified: "NIP-05 niezweryfikowany względem tego klucza", message: "Wiadomość", eventsHosted: "Organizowane wydarzenia", + labServices: "Usługi w The Lab", openToSponsorships: "Otwarty na sponsoring", forSponsors: "Dla sponsorów i współprac", sponsorPitch: "Sponsoring, współpraca, foto/wideo lub wydarzenie? Rozpocznij rozmowę.", diff --git a/src/i18n/pl/drift.ts b/src/i18n/pl/drift.ts index 782c743..1e1a777 100644 --- a/src/i18n/pl/drift.ts +++ b/src/i18n/pl/drift.ts @@ -75,6 +75,7 @@ const drift = { "offerTransport": "Oferuj transport", "labLink": { "transport": "Transport", + "designer": "Odzież", }, "detail": { "noun": { diff --git a/src/i18n/pt/batch4.ts b/src/i18n/pt/batch4.ts index 53ebac2..df6407b 100644 --- a/src/i18n/pt/batch4.ts +++ b/src/i18n/pt/batch4.ts @@ -1095,6 +1095,7 @@ const ptBatch4 = { nip05NotVerified: 'NIP-05 não verificado para esta chave', message: 'Mensagem', eventsHosted: 'Eventos organizados', + labServices: 'Serviços do Lab', openToSponsorships: 'Aberto a patrocínios', forSponsors: 'Para patrocinadores e colaborações', sponsorPitch: 'Patrocínio, colaboração, foto/vídeo ou evento? Comece uma conversa.', diff --git a/src/i18n/pt/drift.ts b/src/i18n/pt/drift.ts index bd8eaea..db8d1f8 100644 --- a/src/i18n/pt/drift.ts +++ b/src/i18n/pt/drift.ts @@ -77,6 +77,7 @@ const drift = { offerTransport: "Oferecer transporte", labLink: { transport: "Transporte", + designer: "Roupas", }, detail: { noun: { diff --git a/src/i18n/ro/batch4.ts b/src/i18n/ro/batch4.ts index 4eb0c26..f03caae 100644 --- a/src/i18n/ro/batch4.ts +++ b/src/i18n/ro/batch4.ts @@ -960,6 +960,7 @@ const roBatch4 = { nip05NotVerified: 'NIP-05 neverificat pentru această cheie', message: 'Mesaj', eventsHosted: 'Evenimente organizate', + labServices: 'Servicii Lab', openToSponsorships: 'Deschis la sponsorizări', forSponsors: 'Pentru sponsori și colaborări', sponsorPitch: 'Sponsorizare, colaborare, foto/video sau eveniment? Începe o conversație.', diff --git a/src/i18n/ro/drift.ts b/src/i18n/ro/drift.ts index 917ee15..e8e62d8 100644 --- a/src/i18n/ro/drift.ts +++ b/src/i18n/ro/drift.ts @@ -75,6 +75,7 @@ const drift = { "offerTransport": "Oferă transport", "labLink": { "transport": "Transport", + "designer": "Îmbrăcăminte", }, "detail": { "noun": { diff --git a/src/i18n/ru/batch4.ts b/src/i18n/ru/batch4.ts index bd530b7..99aa2db 100644 --- a/src/i18n/ru/batch4.ts +++ b/src/i18n/ru/batch4.ts @@ -960,6 +960,7 @@ const ruBatch4 = { nip05NotVerified: 'NIP-05 не подтверждён для этого ключа', message: 'Сообщение', eventsHosted: 'Проведённые мероприятия', + labServices: 'Услуги The Lab', openToSponsorships: 'Открыт для спонсорства', forSponsors: 'Для спонсоров и коллабораций', sponsorPitch: 'Спонсорство, коллаборация, фото/видео или мероприятие? Начните разговор.', diff --git a/src/i18n/ru/drift.ts b/src/i18n/ru/drift.ts index 9a4343a..7563da2 100644 --- a/src/i18n/ru/drift.ts +++ b/src/i18n/ru/drift.ts @@ -79,6 +79,7 @@ const drift = { offerTransport: "Предложить транспорт", labLink: { transport: "Транспорт", + designer: "Одежда", }, detail: { noun: { diff --git a/src/i18n/sv/batch4.ts b/src/i18n/sv/batch4.ts index b4a0570..49de658 100644 --- a/src/i18n/sv/batch4.ts +++ b/src/i18n/sv/batch4.ts @@ -960,6 +960,7 @@ const svBatch4 = { nip05NotVerified: "NIP-05 inte verifierad mot den här nyckeln", message: "Meddelande", eventsHosted: "Arrangerade evenemang", + labServices: "Lab-tjänster", openToSponsorships: "Öppen för sponsring", forSponsors: "För sponsorer & samarbeten", sponsorPitch: "Sponsring, samarbete, foto/video eller evenemang? Starta ett samtal.", diff --git a/src/i18n/sv/drift.ts b/src/i18n/sv/drift.ts index e34b91f..82a14f7 100644 --- a/src/i18n/sv/drift.ts +++ b/src/i18n/sv/drift.ts @@ -79,6 +79,7 @@ const drift = { offerTransport: "Erbjud transport", labLink: { transport: "Transport", + designer: "Kläder", }, detail: { noun: { diff --git a/src/i18n/th/batch9.ts b/src/i18n/th/batch9.ts index 443bd5f..547a588 100644 --- a/src/i18n/th/batch9.ts +++ b/src/i18n/th/batch9.ts @@ -1069,6 +1069,7 @@ const thBatch9 = { nip05NotVerified: 'NIP-05 ยังไม่ได้รับการยืนยันกับกุญแจนี้', message: 'ข้อความ', eventsHosted: 'กิจกรรมที่จัดขึ้น', + labServices: 'บริการ Lab', openToSponsorships: 'เปิดรับสปอนเซอร์', forSponsors: 'สำหรับสปอนเซอร์และความร่วมมือ', sponsorPitch: 'การสนับสนุน ความร่วมมือ ภาพ/วิดีโอ หรืออีเวนต์? เริ่มคุยกันได้เลย', diff --git a/src/i18n/th/drift.ts b/src/i18n/th/drift.ts index 85b5922..7b7daee 100644 --- a/src/i18n/th/drift.ts +++ b/src/i18n/th/drift.ts @@ -71,6 +71,7 @@ const driftStrings = { offerTransport: 'เสนอบริการขนส่ง', labLink: { transport: 'ขนส่ง', + designer: 'เสื้อผ้า', }, detail: { noun: { diff --git a/src/i18n/tr/batch4.ts b/src/i18n/tr/batch4.ts index 65506c0..5ce6923 100644 --- a/src/i18n/tr/batch4.ts +++ b/src/i18n/tr/batch4.ts @@ -960,6 +960,7 @@ const trBatch4 = { nip05NotVerified: "NIP-05 bu anahtara karşı doğrulanmadı", message: "Mesaj", eventsHosted: "Düzenlenen etkinlikler", + labServices: "The Lab hizmetleri", openToSponsorships: "Sponsorluğa açık", forSponsors: "Sponsorlar ve iş birlikleri için", sponsorPitch: "Sponsorluk, iş birliği, fotoğraf/video veya etkinlik mi? Hemen konuşmaya başla.", diff --git a/src/i18n/tr/drift.ts b/src/i18n/tr/drift.ts index 9444ee6..4d99d85 100644 --- a/src/i18n/tr/drift.ts +++ b/src/i18n/tr/drift.ts @@ -71,6 +71,7 @@ const driftStrings = { offerTransport: 'Ulaşım sun', labLink: { transport: 'Ulaşım', + designer: 'Giyim', }, detail: { noun: { diff --git a/src/i18n/uk/batch4.ts b/src/i18n/uk/batch4.ts index bd6f344..296c995 100644 --- a/src/i18n/uk/batch4.ts +++ b/src/i18n/uk/batch4.ts @@ -960,6 +960,7 @@ const ukBatch4 = { nip05NotVerified: "NIP-05 не підтверджено для цього ключа", message: "Повідомлення", eventsHosted: "Проведені події", + labServices: "Послуги Lab", openToSponsorships: "Відкритий до спонсорства", forSponsors: "Для спонсорів і колаборацій", sponsorPitch: "Спонсорство, колаборація, фото/відео чи подія? Почніть розмову.", diff --git a/src/i18n/uk/drift.ts b/src/i18n/uk/drift.ts index 6e68c2e..146902a 100644 --- a/src/i18n/uk/drift.ts +++ b/src/i18n/uk/drift.ts @@ -79,6 +79,7 @@ const drift = { offerTransport: "Запропонувати транспорт", labLink: { transport: "Транспорт", + designer: "Одяг", }, detail: { noun: { diff --git a/src/i18n/vi/batch4.ts b/src/i18n/vi/batch4.ts index dba91df..2b9ba3f 100644 --- a/src/i18n/vi/batch4.ts +++ b/src/i18n/vi/batch4.ts @@ -968,6 +968,7 @@ const viBatch4 = { nip05NotVerified: 'NIP-05 chưa được xác minh với khóa này', message: 'Nhắn tin', eventsHosted: 'Sự kiện đã tổ chức', + labServices: 'Dịch vụ Lab', openToSponsorships: 'Sẵn sàng nhận tài trợ', forSponsors: 'Dành cho nhà tài trợ & hợp tác', sponsorPitch: 'Tài trợ, hợp tác, ảnh/video hay sự kiện? Bắt đầu trò chuyện nhé.', diff --git a/src/i18n/vi/drift.ts b/src/i18n/vi/drift.ts index d01fbb9..e76d254 100644 --- a/src/i18n/vi/drift.ts +++ b/src/i18n/vi/drift.ts @@ -71,6 +71,7 @@ const driftStrings = { offerTransport: 'Cung cấp vận chuyển', labLink: { transport: 'Vận chuyển', + designer: 'Quần áo', }, detail: { noun: { diff --git a/src/i18n/zh-CN/batch4.ts b/src/i18n/zh-CN/batch4.ts index d1be0fe..289ef62 100644 --- a/src/i18n/zh-CN/batch4.ts +++ b/src/i18n/zh-CN/batch4.ts @@ -965,6 +965,7 @@ const zhCNBatch4 = { nip05NotVerified: "NIP-05 与此密钥未通过验证", message: "私信", eventsHosted: "主办的活动", + labServices: "Lab 服务", openToSponsorships: "开放赞助合作", forSponsors: "赞助与合作", sponsorPitch: "赞助、合作、拍摄照片/视频还是办活动?来聊聊吧。", diff --git a/src/i18n/zh-CN/drift.ts b/src/i18n/zh-CN/drift.ts index 080451d..91ceebc 100644 --- a/src/i18n/zh-CN/drift.ts +++ b/src/i18n/zh-CN/drift.ts @@ -87,6 +87,7 @@ const drift: Dict = { offerTransport: '提供交通服务', labLink: { transport: '交通', + designer: '服装', }, detail: { noun: { diff --git a/src/i18n/zh-TW/batch4.ts b/src/i18n/zh-TW/batch4.ts index b507327..47b938f 100644 --- a/src/i18n/zh-TW/batch4.ts +++ b/src/i18n/zh-TW/batch4.ts @@ -965,6 +965,7 @@ const zhTWBatch4 = { nip05NotVerified: "NIP-05 與此金鑰未透過驗證", message: "私信", eventsHosted: "主辦的活動", + labServices: "Lab 服務", openToSponsorships: "開放贊助合作", forSponsors: "贊助與合作", sponsorPitch: "贊助、合作、拍攝照片/影片還是辦活動?來聊聊吧。", diff --git a/src/i18n/zh-TW/drift.ts b/src/i18n/zh-TW/drift.ts index 9946c17..d42a3a2 100644 --- a/src/i18n/zh-TW/drift.ts +++ b/src/i18n/zh-TW/drift.ts @@ -87,6 +87,7 @@ const drift: Dict = { offerTransport: '提供交通服務', labLink: { transport: '交通', + designer: '服飾', }, detail: { noun: { diff --git a/src/lib/transport.test.ts b/src/lib/transport.test.ts new file mode 100644 index 0000000..da3752d --- /dev/null +++ b/src/lib/transport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { SKATE_TRANSPORT_KIND, parseSkateTransport } from './types'; + +function transportEvent(overrides: Partial = {}): NostrEvent { + return { + id: 'b'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 1_700_000_000, + kind: SKATE_TRANSPORT_KIND, + content: 'Airport runs and event gear moves.', + sig: 'c'.repeat(128), + tags: [ + ['d', 'transport-profile'], + ['title', 'BCN Skate Transfers'], + ['t', 'skatetransport'], + ['t', 'van'], + ['t', 'people'], + ['t', 'gear'], + ['t', 'cargo'], + ['t', 'solo'], + ['t', 'regional'], + ['location', 'Barcelona'], + ['capacity', '4 passengers / 2 boards'], + ['coverage', 'Barcelona ↔ Perpignan'], + ['accessibility', 'wheelchair space'], + ], + ...overrides, + }; +} + +describe('parseSkateTransport', () => { + it('parses vehicle, service, capability, capacity and coverage tags', () => { + const profile = parseSkateTransport(transportEvent()); + expect(profile).not.toBeNull(); + expect(profile!.identifier).toBe('transport-profile'); + expect(profile!.title).toBe('BCN Skate Transfers'); + expect(profile!.skills).toEqual( + expect.arrayContaining(['van', 'people', 'gear', 'cargo', 'solo', 'regional']), + ); + expect(profile!.skills).not.toContain('skatetransport'); + expect(profile!.location).toBe('Barcelona'); + }); + + it('rejects a transport event without a d or title', () => { + expect(parseSkateTransport(transportEvent({ tags: [['t', 'skatetransport']] }))).toBeNull(); + expect(parseSkateTransport(transportEvent({ tags: [['d', 'x'], ['t', 'skatetransport']] }))).toBeNull(); + }); + + it('rejects other Lab kinds', () => { + expect(parseSkateTransport(transportEvent({ kind: 35015 }))).toBeNull(); + }); +}); diff --git a/src/lib/xlsx.test.ts b/src/lib/xlsx.test.ts new file mode 100644 index 0000000..738c10d --- /dev/null +++ b/src/lib/xlsx.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { strFromU8, unzipSync } from 'fflate'; +import { buildXlsx } from './xlsx'; + +function entries(bytes: Uint8Array): Record { + const files = unzipSync(bytes); + return Object.fromEntries(Object.entries(files).map(([name, data]) => [name, strFromU8(data)])); +} + +describe('buildXlsx', () => { + it('builds the required OOXML parts and escapes text', () => { + const files = entries(buildXlsx([ + { name: 'Summary/Test', rows: [['Name', ''], ['Deck', 42.5]] }, + ])); + + expect(Object.keys(files)).toEqual(expect.arrayContaining([ + '[Content_Types].xml', + '_rels/.rels', + 'xl/workbook.xml', + 'xl/_rels/workbook.xml.rels', + 'xl/worksheets/sheet1.xml', + ])); + expect(files['xl/workbook.xml']).toContain('name="Summary Test"'); + expect(files['xl/workbook.xml']).not.toContain('Summary/Test'); + expect(files['xl/worksheets/sheet1.xml']).toContain('<value>'); + expect(files['xl/worksheets/sheet1.xml']).toContain('42.5'); + expect(files['xl/worksheets/sheet1.xml']).toContain('r="A2"'); + }); + + it('keeps sheet names unique and within Excel’s 31-character limit', () => { + const long = 'A very long spreadsheet sheet name that exceeds the limit'; + const files = entries(buildXlsx([ + { name: long, rows: [['one']] }, + { name: long, rows: [['two']] }, + ])); + const workbook = files['xl/workbook.xml']; + expect(workbook).toContain('name="A very long spreadsheet sheet n"'); + expect(workbook).toContain('name="A very long spreadsheet she (2)"'); + expect(workbook).not.toContain(long); + }); + + it('falls back to a sheet when the caller passes none', () => { + const files = entries(buildXlsx([])); + expect(files['xl/workbook.xml']).toContain('name="Sheet 1"'); + }); +}); diff --git a/src/lib/xlsx.ts b/src/lib/xlsx.ts new file mode 100644 index 0000000..a8f0acb --- /dev/null +++ b/src/lib/xlsx.ts @@ -0,0 +1,125 @@ +/** + * Tiny, dependency-light XLSX writer for the few spreadsheet exports in the app. + * + * The app only ever WRITES XLSX (the deck-cost sheet); it never parses + * untrusted spreadsheets. Keeping a local OOXML writer avoids carrying the + * vulnerable `xlsx` parser for a write-only feature, and keeps the export + * chunk tiny. + * + * Produces a standards-compliant single-workbook XLSX with inline strings. + * Deliberately no styles/formulas — the exported data is plain rows. + */ +import { zipSync, strToU8 } from 'fflate'; + +export type XlsxCell = string | number | null | undefined; +export type XlsxSheet = { name: string; rows: XlsxCell[][] }; + +const XML_HEADER = ''; + +function escapeXml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function safeSheetName(name: string, index: number, used: Set): string { + const base = name.replace(/[\\/?*[\]:]/g, ' ').trim().slice(0, 31) || `Sheet ${index + 1}`; + let candidate = base; + let n = 2; + while (used.has(candidate.toLowerCase())) { + const suffix = ` (${n++})`; + candidate = `${base.slice(0, Math.max(1, 31 - suffix.length))}${suffix}`; + } + used.add(candidate.toLowerCase()); + return candidate; +} + +function columnName(index: number): string { + let n = index + 1; + let out = ''; + while (n > 0) { + const rem = (n - 1) % 26; + out = String.fromCharCode(65 + rem) + out; + n = Math.floor((n - 1) / 26); + } + return out; +} + +function cellXml(value: XlsxCell, ref: string): string { + if (value === null || value === undefined || value === '') return ''; + if (typeof value === 'number' && Number.isFinite(value)) { + return `${value}`; + } + return `${escapeXml(String(value))}`; +} + +function sheetXml(rows: XlsxCell[][]): string { + const body = rows + .map((row, rowIndex) => { + const cells = row + .map((value, colIndex) => cellXml(value, `${columnName(colIndex)}${rowIndex + 1}`)) + .join(''); + return cells ? `${cells}` : ''; + }) + .join(''); + return `${XML_HEADER}${body}`; +} + +/** Build a valid XLSX byte array. Exported for tests and non-browser callers. */ +export function buildXlsx(sheets: XlsxSheet[]): Uint8Array { + const safeSheets = (sheets.length ? sheets : [{ name: 'Sheet 1', rows: [] }]).map((sheet, index) => ({ + ...sheet, + _safeName: '', + _index: index, + })); + const used = new Set(); + for (const sheet of safeSheets) sheet._safeName = safeSheetName(sheet.name, sheet._index, used); + + const contentTypes = `${XML_HEADER}` + + '' + + '' + + '' + + safeSheets.map((_, i) => ``).join('') + + ''; + + const rels = `${XML_HEADER}` + + '' + + ''; + + const workbook = `${XML_HEADER}` + + safeSheets.map((sheet, i) => ``).join('') + + ''; + + const workbookRels = `${XML_HEADER}` + + safeSheets.map((_, i) => ``).join('') + + ''; + + const files: Record = { + '[Content_Types].xml': strToU8(contentTypes), + '_rels/.rels': strToU8(rels), + 'xl/workbook.xml': strToU8(workbook), + 'xl/_rels/workbook.xml.rels': strToU8(workbookRels), + }; + safeSheets.forEach((sheet, i) => { + files[`xl/worksheets/sheet${i + 1}.xml`] = strToU8(sheetXml(sheet.rows)); + }); + + return zipSync(files, { level: 6 }); +} + +/** Trigger a browser download of a built workbook. */ +export function downloadXlsx(filename: string, sheets: XlsxSheet[]): void { + const bytes = buildXlsx(sheets); + const blob = new Blob([bytes], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}