feat: profile Lab services strip and dependency-light XLSX export
This commit is contained in:
parent
bf47d43674
commit
7a928dae6d
57 changed files with 326 additions and 118 deletions
105
package-lock.json
generated
105
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const drift = {
|
|||
},
|
||||
labLink: {
|
||||
transport: 'Transport',
|
||||
designer: 'Roba',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const drift = {
|
|||
"offerTransport": "Nabídnout dopravu",
|
||||
"labLink": {
|
||||
"transport": "Doprava",
|
||||
"designer": "Oblečení",
|
||||
},
|
||||
"detail": {
|
||||
"noun": {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const drift = {
|
|||
offerTransport: "Transport anbieten",
|
||||
labLink: {
|
||||
transport: "Transport",
|
||||
designer: "Kleidung",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ const elBatch4 = {
|
|||
nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί",
|
||||
message: "Μήνυμα",
|
||||
eventsHosted: "Εκδηλώσεις που διοργάνωσες",
|
||||
labServices: "Υπηρεσίες Lab",
|
||||
openToSponsorships: "Ανοιχτός σε χορηγίες",
|
||||
forSponsors: "Για χορηγούς και συνεργασίες",
|
||||
sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const drift = {
|
|||
offerTransport: "Πρόσφερε μεταφορές",
|
||||
labLink: {
|
||||
transport: "Μεταφορές",
|
||||
designer: "Ρούχα",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const drift = {
|
|||
},
|
||||
labLink: {
|
||||
transport: 'Garraioa',
|
||||
designer: 'Arropa',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const drift = {
|
|||
offerTransport: "Proposer un transport",
|
||||
labLink: {
|
||||
transport: "Transport",
|
||||
designer: "Vêtements",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const drift = {
|
|||
},
|
||||
labLink: {
|
||||
transport: 'Transporte',
|
||||
designer: 'Roupa',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -986,6 +986,7 @@ const batch6 = {
|
|||
"nip05NotVerified": "NIP-05 इस कुंजी से सत्यापित नहीं",
|
||||
"message": "संदेश",
|
||||
"eventsHosted": "आयोजित कार्यक्रम",
|
||||
"labServices": "Lab सेवाएँ",
|
||||
"openToSponsorships": "प्रायोजन के लिए उपलब्ध",
|
||||
"forSponsors": "प्रायोजकों और सहयोगों के लिए",
|
||||
"sponsorPitch": "प्रायोजन, सहयोग, फ़ोटो/वीडियो या कार्यक्रम? बातचीत शुरू करें।",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const drift = {
|
|||
},
|
||||
labLink: {
|
||||
transport: 'परिवहन',
|
||||
designer: 'कपड़े',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const driftStrings = {
|
|||
offerTransport: 'Tawarkan transportasi',
|
||||
labLink: {
|
||||
transport: 'Transportasi',
|
||||
designer: 'Pakaian',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const drift = {
|
|||
offerTransport: "Offri trasporto",
|
||||
labLink: {
|
||||
transport: "Trasporto",
|
||||
designer: "Abbigliamento",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -969,6 +969,7 @@ const jaBatch7 = {
|
|||
nip05NotVerified: 'NIP-05がこの鍵で認証されていません',
|
||||
message: 'メッセージ',
|
||||
eventsHosted: '主催イベント',
|
||||
labServices: 'Labサービス',
|
||||
openToSponsorships: 'スポンサー募集中',
|
||||
forSponsors: 'スポンサー・コラボ募集',
|
||||
sponsorPitch: 'スポンサー、コラボ、写真/動画、イベントの依頼はありませんか?まずはご相談ください。',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const drift: Dict = {
|
|||
offerTransport: '輸送を提供',
|
||||
labLink: {
|
||||
transport: '輸送',
|
||||
designer: '服',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -968,6 +968,7 @@ const koBatch4 = {
|
|||
nip05NotVerified: '이 키에 대해 NIP-05가 인증되지 않음',
|
||||
message: '메시지',
|
||||
eventsHosted: '주최한 이벤트',
|
||||
labServices: 'Lab 서비스',
|
||||
openToSponsorships: '스폰서 모집 중',
|
||||
forSponsors: '스폰서 및 협업 문의',
|
||||
sponsorPitch: '스폰서십, 협업, 사진/영상 또는 이벤트? 대화를 시작해 보세요.',
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const drift: Dict = {
|
|||
offerTransport: '운송 제공',
|
||||
labLink: {
|
||||
transport: '운송',
|
||||
designer: '의류',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const drift = {
|
|||
"offerTransport": "Transport aanbieden",
|
||||
"labLink": {
|
||||
"transport": "Transport",
|
||||
"designer": "Kleding",
|
||||
},
|
||||
"detail": {
|
||||
"noun": {
|
||||
|
|
|
|||
|
|
@ -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ę.",
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const drift = {
|
|||
"offerTransport": "Oferuj transport",
|
||||
"labLink": {
|
||||
"transport": "Transport",
|
||||
"designer": "Odzież",
|
||||
},
|
||||
"detail": {
|
||||
"noun": {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const drift = {
|
|||
offerTransport: "Oferecer transporte",
|
||||
labLink: {
|
||||
transport: "Transporte",
|
||||
designer: "Roupas",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const drift = {
|
|||
"offerTransport": "Oferă transport",
|
||||
"labLink": {
|
||||
"transport": "Transport",
|
||||
"designer": "Îmbrăcăminte",
|
||||
},
|
||||
"detail": {
|
||||
"noun": {
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ const ruBatch4 = {
|
|||
nip05NotVerified: 'NIP-05 не подтверждён для этого ключа',
|
||||
message: 'Сообщение',
|
||||
eventsHosted: 'Проведённые мероприятия',
|
||||
labServices: 'Услуги The Lab',
|
||||
openToSponsorships: 'Открыт для спонсорства',
|
||||
forSponsors: 'Для спонсоров и коллабораций',
|
||||
sponsorPitch: 'Спонсорство, коллаборация, фото/видео или мероприятие? Начните разговор.',
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const drift = {
|
|||
offerTransport: "Предложить транспорт",
|
||||
labLink: {
|
||||
transport: "Транспорт",
|
||||
designer: "Одежда",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const drift = {
|
|||
offerTransport: "Erbjud transport",
|
||||
labLink: {
|
||||
transport: "Transport",
|
||||
designer: "Kläder",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -1069,6 +1069,7 @@ const thBatch9 = {
|
|||
nip05NotVerified: 'NIP-05 ยังไม่ได้รับการยืนยันกับกุญแจนี้',
|
||||
message: 'ข้อความ',
|
||||
eventsHosted: 'กิจกรรมที่จัดขึ้น',
|
||||
labServices: 'บริการ Lab',
|
||||
openToSponsorships: 'เปิดรับสปอนเซอร์',
|
||||
forSponsors: 'สำหรับสปอนเซอร์และความร่วมมือ',
|
||||
sponsorPitch: 'การสนับสนุน ความร่วมมือ ภาพ/วิดีโอ หรืออีเวนต์? เริ่มคุยกันได้เลย',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const driftStrings = {
|
|||
offerTransport: 'เสนอบริการขนส่ง',
|
||||
labLink: {
|
||||
transport: 'ขนส่ง',
|
||||
designer: 'เสื้อผ้า',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const driftStrings = {
|
|||
offerTransport: 'Ulaşım sun',
|
||||
labLink: {
|
||||
transport: 'Ulaşım',
|
||||
designer: 'Giyim',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ const ukBatch4 = {
|
|||
nip05NotVerified: "NIP-05 не підтверджено для цього ключа",
|
||||
message: "Повідомлення",
|
||||
eventsHosted: "Проведені події",
|
||||
labServices: "Послуги Lab",
|
||||
openToSponsorships: "Відкритий до спонсорства",
|
||||
forSponsors: "Для спонсорів і колаборацій",
|
||||
sponsorPitch: "Спонсорство, колаборація, фото/відео чи подія? Почніть розмову.",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const drift = {
|
|||
offerTransport: "Запропонувати транспорт",
|
||||
labLink: {
|
||||
transport: "Транспорт",
|
||||
designer: "Одяг",
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -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é.',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -965,6 +965,7 @@ const zhCNBatch4 = {
|
|||
nip05NotVerified: "NIP-05 与此密钥未通过验证",
|
||||
message: "私信",
|
||||
eventsHosted: "主办的活动",
|
||||
labServices: "Lab 服务",
|
||||
openToSponsorships: "开放赞助合作",
|
||||
forSponsors: "赞助与合作",
|
||||
sponsorPitch: "赞助、合作、拍摄照片/视频还是办活动?来聊聊吧。",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const drift: Dict = {
|
|||
offerTransport: '提供交通服务',
|
||||
labLink: {
|
||||
transport: '交通',
|
||||
designer: '服装',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
|
|
@ -965,6 +965,7 @@ const zhTWBatch4 = {
|
|||
nip05NotVerified: "NIP-05 與此金鑰未透過驗證",
|
||||
message: "私信",
|
||||
eventsHosted: "主辦的活動",
|
||||
labServices: "Lab 服務",
|
||||
openToSponsorships: "開放贊助合作",
|
||||
forSponsors: "贊助與合作",
|
||||
sponsorPitch: "贊助、合作、拍攝照片/影片還是辦活動?來聊聊吧。",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const drift: Dict = {
|
|||
offerTransport: '提供交通服務',
|
||||
labLink: {
|
||||
transport: '交通',
|
||||
designer: '服飾',
|
||||
},
|
||||
detail: {
|
||||
noun: {
|
||||
|
|
|
|||
53
src/lib/transport.test.ts
Normal file
53
src/lib/transport.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
46
src/lib/xlsx.test.ts
Normal file
46
src/lib/xlsx.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { buildXlsx } from './xlsx';
|
||||
|
||||
function entries(bytes: Uint8Array): Record<string, string> {
|
||||
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', '<value>'], ['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('<v>42.5</v>');
|
||||
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"');
|
||||
});
|
||||
});
|
||||
125
src/lib/xlsx.ts
Normal file
125
src/lib/xlsx.ts
Normal file
|
|
@ -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 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function safeSheetName(name: string, index: number, used: Set<string>): 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 `<c r="${ref}"><v>${value}</v></c>`;
|
||||
}
|
||||
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(String(value))}</t></is></c>`;
|
||||
}
|
||||
|
||||
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 ? `<row r="${rowIndex + 1}">${cells}</row>` : '';
|
||||
})
|
||||
.join('');
|
||||
return `${XML_HEADER}<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
|
||||
}
|
||||
|
||||
/** 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<string>();
|
||||
for (const sheet of safeSheets) sheet._safeName = safeSheetName(sheet.name, sheet._index, used);
|
||||
|
||||
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
||||
safeSheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('') +
|
||||
'</Types>';
|
||||
|
||||
const rels = `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
||||
'</Relationships>';
|
||||
|
||||
const workbook = `${XML_HEADER}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
|
||||
safeSheets.map((sheet, i) => `<sheet name="${escapeXml(sheet._safeName)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('') +
|
||||
'</sheets></workbook>';
|
||||
|
||||
const workbookRels = `${XML_HEADER}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
|
||||
safeSheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('') +
|
||||
'</Relationships>';
|
||||
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'[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);
|
||||
}
|
||||
Loading…
Reference in a new issue