diff --git a/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx b/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx index eb047f7..ff3d932 100644 --- a/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx +++ b/apps/web/app/(dashboard)/w/[slug]/tracked-links/[id]/page.tsx @@ -23,7 +23,7 @@ import { Mail, } from 'lucide-react'; import { trpc } from '@/lib/trpc/client'; -import { formatDate, getRelativeTime } from '@/lib/utils'; +import { formatDate, formatDuration, getRelativeTime } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { FileIcon } from '@/components/file-icon'; @@ -314,7 +314,7 @@ export default function TrackedLinkDetailPage({ label="Avg. Duration" value={ analytics?.avgDurationSeconds != null - ? `${analytics.avgDurationSeconds}s` + ? formatDuration(analytics.avgDurationSeconds) : '--' } sub={ diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx index 00d2448..d40e3f3 100644 --- a/apps/web/app/shared/[token]/page.tsx +++ b/apps/web/app/shared/[token]/page.tsx @@ -7,10 +7,12 @@ import { AlertCircle, Folder, ChevronRight, + ArrowLeft, } from "lucide-react"; import { Logo } from "@/assets/logo"; import { trpc } from "@/lib/trpc/client"; -import { formatBytes } from "@/lib/utils"; +import { cn, formatBytes } from "@/lib/utils"; +import { SharedFilePreview } from "@/features/files/shared-preview"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FileIcon } from "@/components/file-icon"; @@ -25,6 +27,12 @@ export default function SharedPage({ const [password, setPassword] = useState(""); const [enteredPassword, setEnteredPassword] = useState(); const [currentFolderId, setCurrentFolderId] = useState(null); + const [previewFile, setPreviewFile] = useState<{ + fileId?: string; + name: string; + mimeType: string; + size: number; + } | null>(null); const { data, isLoading } = trpc.shares.access.useQuery({ token, @@ -54,6 +62,7 @@ export default function SharedPage({ ); const getDownloadUrl = trpc.shares.getDownloadUrl.useMutation(); + const getPreviewUrl = trpc.shares.getPreviewUrl.useMutation(); const handleDownload = async (fileId?: string) => { try { @@ -172,7 +181,12 @@ export default function SharedPage({ return (
-
+
Locker @@ -181,9 +195,56 @@ export default function SharedPage({
- {item.type === "file" ? ( + {previewFile ? (
-
+
+ +

+ {previewFile.name} +

+ +
+ + + getPreviewUrl + .mutateAsync({ + token, + fileId: previewFile.fileId, + password: enteredPassword, + }) + .then((r) => r.url) + } + onDownload={() => handleDownload(previewFile.fileId)} + /> +
+ ) : item.type === "file" ? ( +
+
-
+ + {formatBytes(file.size)} diff --git a/apps/web/app/t/[token]/page.tsx b/apps/web/app/t/[token]/page.tsx index a2d9ff1..176c3eb 100644 --- a/apps/web/app/t/[token]/page.tsx +++ b/apps/web/app/t/[token]/page.tsx @@ -8,10 +8,12 @@ import { Folder, Mail, ChevronRight, + ArrowLeft, } from "lucide-react"; import { Logo } from "@/assets/logo"; import { trpc } from "@/lib/trpc/client"; -import { formatBytes } from "@/lib/utils"; +import { cn, formatBytes } from "@/lib/utils"; +import { SharedFilePreview } from "@/features/files/shared-preview"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { FileIcon } from "@/components/file-icon"; @@ -39,6 +41,12 @@ export default function TrackedLinkPage({ const eventIdRef = useRef(null); const startTimeRef = useRef(Date.now()); const [currentFolderId, setCurrentFolderId] = useState(null); + const [previewFile, setPreviewFile] = useState<{ + fileId?: string; + name: string; + mimeType: string; + size: number; + } | null>(null); const { data, isLoading } = trpc.trackedLinks.access.useQuery({ token, @@ -57,6 +65,7 @@ export default function TrackedLinkPage({ ); const getDownloadUrl = trpc.trackedLinks.getDownloadUrl.useMutation(); + const getPreviewUrl = trpc.trackedLinks.getPreviewUrl.useMutation(); // Send tracking beacon when access succeeds const sendTrackingBeacon = useCallback(async () => { @@ -279,7 +288,12 @@ export default function TrackedLinkPage({ return (
-
+
Locker @@ -288,9 +302,63 @@ export default function TrackedLinkPage({
- {item.type === "file" ? ( + {previewFile ? ( +
+
+ +

+ {previewFile.name} +

+ {access === "download" && ( + + )} +
+ + + getPreviewUrl + .mutateAsync({ + token, + fileId: previewFile.fileId, + password: enteredPassword, + email: enteredEmail, + }) + .then((r) => r.url) + } + onDownload={ + access === "download" + ? () => handleDownload(previewFile.fileId) + : undefined + } + /> +
+ ) : item.type === "file" ? (
-
+
-
+ + {access === "download" && ( {formatBytes(file.size)} diff --git a/apps/web/components/pdf-viewer.tsx b/apps/web/components/pdf-viewer.tsx index ec669ff..bb42e8b 100644 --- a/apps/web/components/pdf-viewer.tsx +++ b/apps/web/components/pdf-viewer.tsx @@ -525,6 +525,8 @@ export function PDFViewer({ }, [url, onLoadSuccess, onLoadError]); /* ---- Observe container width ---- */ + // Re-run when loading finishes: the scroll container only exists once the + // loading skeleton is replaced, so a mount-only effect would never attach. useEffect(() => { const container = scrollContainerRef.current; if (!container) return; @@ -536,7 +538,7 @@ export function PDFViewer({ }); observer.observe(container); return () => observer.disconnect(); - }, []); + }, [isLoading]); /* ---- Observe visible pages ---- */ useEffect(() => { @@ -612,17 +614,11 @@ export function PDFViewer({ [pages.length, scrollToPage, onPageChangeProp], ); - /* ---- Auto-scale on load ---- */ - useEffect(() => { - if (pages.length === 0 || !containerWidth) return; - const firstPage = pages[0]; - if (!firstPage) return; - const viewport = firstPage.getViewport({ scale: 1 }); - const fitScale = (containerWidth - 48) / viewport.width; - if (fitScale < 1) setScale(fitScale); - }, [pages, containerWidth]); - /* ---- Zoom handlers ---- */ + // PDFPage renders at scale * min(pageFitScale, 1), so pages wider than the + // container (e.g. landscape) already fit the full width at scale 1 — no + // auto-scale on load needed, and the fit handlers below must divide that + // clamp back out to hit an exact target scale. const handleScaleChange = useCallback((s: number) => { setScale(Math.max(MIN_SCALE, Math.min(MAX_SCALE, s))); }, []); @@ -632,7 +628,8 @@ export function PDFViewer({ const firstPage = pages[0]; if (!firstPage) return; const vp = firstPage.getViewport({ scale: 1 }); - setScale((containerWidth - 48) / vp.width); + const fitScale = (containerWidth - 48) / vp.width; + setScale(Math.max(fitScale, 1)); }, [pages, containerWidth]); const handleFitPage = useCallback(() => { @@ -645,7 +642,7 @@ export function PDFViewer({ const containerHeight = container.clientHeight - PAGE_GAP * 2; const widthScale = (containerWidth - 48) / vp.width; const heightScale = containerHeight / vp.height; - setScale(Math.min(widthScale, heightScale)); + setScale(Math.min(widthScale, heightScale) / Math.min(widthScale, 1)); }, [pages, containerWidth]); /* ---- Keyboard navigation ---- */ diff --git a/apps/web/components/xlsx-viewer.tsx b/apps/web/components/xlsx-viewer.tsx new file mode 100644 index 0000000..42b391d --- /dev/null +++ b/apps/web/components/xlsx-viewer.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { read, utils, type WorkBook } from "xlsx"; +import { Loader2, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { CsvPreview } from "@/features/files/file-viewer/components/csv-preview"; + +export function XlsxViewer({ url, name }: { url: string; name: string }) { + const [workbook, setWorkbook] = useState(null); + const [error, setError] = useState(null); + const [activeSheet, setActiveSheet] = useState(0); + + useEffect(() => { + let cancelled = false; + setWorkbook(null); + setError(null); + setActiveSheet(0); + + fetch(url) + .then((r) => { + if (!r.ok) throw new Error(`Failed to load file (${r.status})`); + return r.arrayBuffer(); + }) + .then((buf) => { + if (!cancelled) setWorkbook(read(buf)); + }) + .catch((err) => { + if (!cancelled) setError((err as Error).message); + }); + + return () => { + cancelled = true; + }; + }, [url]); + + const csv = useMemo(() => { + if (!workbook) return null; + const sheetName = workbook.SheetNames[activeSheet]; + const sheet = sheetName ? workbook.Sheets[sheetName] : undefined; + return sheet ? utils.sheet_to_csv(sheet) : ""; + }, [workbook, activeSheet]); + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (!workbook) { + return ( +
+ +
+ ); + } + + return ( +
+ {workbook.SheetNames.length > 1 && ( +
+ {workbook.SheetNames.map((sheetName, i) => ( + + ))} +
+ )} +
+ +
+
+ ); +} diff --git a/apps/web/features/files/file-viewer/components/preview-area.tsx b/apps/web/features/files/file-viewer/components/preview-area.tsx index cdaa9c3..7254ee3 100644 --- a/apps/web/features/files/file-viewer/components/preview-area.tsx +++ b/apps/web/features/files/file-viewer/components/preview-area.tsx @@ -5,6 +5,7 @@ import { VideoPreview } from "./video-preview"; import { AudioPreview } from "./audio-preview"; import { PdfPreview } from "./pdf-preview"; import { DocxPreview } from "./docx-preview"; +import { XlsxPreview } from "./xlsx-preview"; import { MarkdownPreview } from "./markdown-preview"; import { TextPreview } from "./text-preview"; import { CsvPreview } from "./csv-preview"; @@ -24,7 +25,7 @@ export function PreviewArea({ textContent: string | null; file: { name: string; mimeType: string; size: number }; loading: boolean; - onDownload: () => void; + onDownload?: () => void; }) { if (loading) { return ( @@ -45,6 +46,8 @@ export function PreviewArea({ return ; case "docx": return ; + case "xlsx": + return ; case "markdown": return ; case "csv": diff --git a/apps/web/features/files/file-viewer/components/unsupported-preview.tsx b/apps/web/features/files/file-viewer/components/unsupported-preview.tsx index 160d751..fef3f59 100644 --- a/apps/web/features/files/file-viewer/components/unsupported-preview.tsx +++ b/apps/web/features/files/file-viewer/components/unsupported-preview.tsx @@ -8,7 +8,7 @@ export function UnsupportedPreview({ onDownload, }: { file: { name: string; mimeType: string; size: number }; - onDownload: () => void; + onDownload?: () => void; }) { return (
@@ -28,10 +28,12 @@ export function UnsupportedPreview({ {formatBytes(file.size)}

- + {onDownload && ( + + )}
); } diff --git a/apps/web/features/files/file-viewer/components/xlsx-preview.tsx b/apps/web/features/files/file-viewer/components/xlsx-preview.tsx new file mode 100644 index 0000000..d9adcd1 --- /dev/null +++ b/apps/web/features/files/file-viewer/components/xlsx-preview.tsx @@ -0,0 +1,16 @@ +import dynamic from "next/dynamic"; + +const XlsxViewer = dynamic( + () => + import("@/components/xlsx-viewer").then((m) => ({ default: m.XlsxViewer })), + { ssr: false }, +); + +export function XlsxPreview({ url, name }: { url: string | null; name: string }) { + if (!url) return null; + return ( +
+ +
+ ); +} diff --git a/apps/web/features/files/file-viewer/types.ts b/apps/web/features/files/file-viewer/types.ts index f375d52..125b3ce 100644 --- a/apps/web/features/files/file-viewer/types.ts +++ b/apps/web/features/files/file-viewer/types.ts @@ -4,6 +4,7 @@ export type ViewerType = | "audio" | "pdf" | "docx" + | "xlsx" | "markdown" | "csv" | "html" diff --git a/apps/web/features/files/shared-preview/index.tsx b/apps/web/features/files/shared-preview/index.tsx new file mode 100644 index 0000000..4674caf --- /dev/null +++ b/apps/web/features/files/shared-preview/index.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { AlertCircle } from "lucide-react"; +import { PreviewArea } from "../file-viewer/components/preview-area"; +import { getViewerType } from "../utils"; + +/** + * Renders a document preview on public share/tracked-link pages. Fetches the + * signed URL via the provided callback so it works for both share links and + * tracked links; `onDownload` is omitted for view-only access. + */ +export function SharedFilePreview({ + file, + fetchUrl, + onDownload, +}: { + file: { name: string; mimeType: string; size: number }; + fetchUrl: () => Promise; + onDownload?: () => void; +}) { + const [previewUrl, setPreviewUrl] = useState(null); + const [textContent, setTextContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + fetchUrl() + .then(async (url) => { + if (cancelled) return; + setPreviewUrl(url); + + const vt = getViewerType(file.mimeType, file.name); + if (vt === "text" || vt === "markdown" || vt === "csv" || vt === "html") { + const text = await fetch(url).then((r) => r.text()); + if (!cancelled) setTextContent(text); + } + if (!cancelled) setLoading(false); + }) + .catch((err) => { + if (!cancelled) { + setError((err as Error).message); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + // Parent remounts this component (via key) when the file changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fixed height so the page doesn't jump as the loader is swapped for the + // rendered document; [&>div]:h-full gives the preview roots a height + // context, matching the in-app viewer's layout. + return ( +
+ {error ? ( +
+ +

{error}

+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/features/files/utils/index.tsx b/apps/web/features/files/utils/index.tsx index 5348d63..62502da 100644 --- a/apps/web/features/files/utils/index.tsx +++ b/apps/web/features/files/utils/index.tsx @@ -62,6 +62,14 @@ export function getViewerType(mimeType: string, name: string): ViewerType { "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) return "docx"; + if ( + ext === "xlsx" || + ext === "xls" || + mimeType === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + mimeType === "application/vnd.ms-excel" + ) + return "xlsx"; if (ext === "md" || ext === "mdx" || mimeType === "text/markdown") return "markdown"; if (ext === "csv" || mimeType === "text/csv") return "csv"; diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index cb19d23..66f39bd 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -13,6 +13,17 @@ export function formatBytes(bytes: number): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } +export function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 1) return "<1s"; + const total = Math.round(seconds); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + if (minutes > 0) return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`; + return `${secs}s`; +} + export function formatDate(date: Date | string): string { return new Date(date).toLocaleDateString("en-US", { month: "short", diff --git a/apps/web/package.json b/apps/web/package.json index cb3ea85..3a2b07f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.3.6" }, "devDependencies": { diff --git a/apps/web/server/trpc/routers/shares.ts b/apps/web/server/trpc/routers/shares.ts index 1d7956c..a620bab 100644 --- a/apps/web/server/trpc/routers/shares.ts +++ b/apps/web/server/trpc/routers/shares.ts @@ -387,6 +387,79 @@ export const sharesRouter = createRouter({ }; }), + // Public: signed URL for rendering a preview (does not count as a download) + getPreviewUrl: publicProcedure + .input( + z.object({ + token: z.string(), + fileId: z.string().uuid().optional(), + password: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const [link] = await ctx.db + .select() + .from(shareLinks) + .where(eq(shareLinks.token, input.token)); + + if (!link || !link.isActive) throw new Error("Link not found"); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + throw new Error("Link expired"); + } + if ( + link.hasPassword && + !verifyLinkPassword(input.password, link.passwordHash) + ) { + throw new Error("Incorrect password"); + } + if (link.maxDownloads && link.downloadCount >= link.maxDownloads) { + throw new Error("Download limit reached"); + } + + let fileId: string; + if (link.fileId) { + if (input.fileId && input.fileId !== link.fileId) { + throw new Error("File not found"); + } + fileId = link.fileId; + } else if (link.folderId) { + if (!input.fileId) throw new Error("No file specified"); + fileId = input.fileId; + } else { + throw new Error("Link target not found"); + } + + const [targetFile] = await ctx.db + .select() + .from(files) + .where( + and( + eq(files.id, fileId), + eq(files.workspaceId, link.workspaceId), + eq(files.status, "ready"), + ), + ); + if (!targetFile) throw new Error("File not found"); + + // For folder shares, verify the file lives inside the shared folder tree + if (link.folderId) { + if (!targetFile.folderId) throw new Error("File not found"); + const allowed = await isDescendantFolder( + ctx.db, + targetFile.folderId, + link.folderId, + ); + if (!allowed) throw new Error("File not found"); + } + + const storage = await createStorageForFile(targetFile.id); + const url = await storage.getSignedUrl( + await getFileStoragePath(targetFile.id), + 3600, + ); + return { url, filename: targetFile.name }; + }), + getDownloadUrl: publicProcedure .input( z.object({ diff --git a/apps/web/server/trpc/routers/tracked-links.ts b/apps/web/server/trpc/routers/tracked-links.ts index ee4c694..1141c7b 100644 --- a/apps/web/server/trpc/routers/tracked-links.ts +++ b/apps/web/server/trpc/routers/tracked-links.ts @@ -610,6 +610,90 @@ export const trackedLinksRouter = createRouter({ }; }), + // Public: signed URL for rendering a preview (allowed for both "view" and + // "download" access; does not count as a download) + getPreviewUrl: publicProcedure + .input( + z.object({ + token: z.string(), + fileId: z.string().uuid().optional(), + password: z.string().optional(), + email: z.string().email().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const [link] = await ctx.db + .select() + .from(trackedLinks) + .where(eq(trackedLinks.token, input.token)); + + if (!link || !link.isActive) throw new Error("Link not found"); + if (link.expiresAt && new Date(link.expiresAt) < new Date()) { + throw new Error("Link expired"); + } + if (link.validFrom && new Date(link.validFrom) > new Date()) { + throw new Error("Link is not yet active"); + } + if (link.validUntil && new Date(link.validUntil) < new Date()) { + throw new Error("Link is no longer active"); + } + if (link.maxViews && link.viewCount >= link.maxViews) { + throw new Error("View limit reached"); + } + if (link.requireEmail && !input.email) { + throw new Error("Email required"); + } + if ( + link.hasPassword && + !verifyLinkPassword(input.password, link.passwordHash) + ) { + throw new Error("Incorrect password"); + } + + let fileId: string; + if (link.fileId) { + if (input.fileId && input.fileId !== link.fileId) { + throw new Error("File not found"); + } + fileId = link.fileId; + } else if (link.folderId) { + if (!input.fileId) throw new Error("No file specified"); + fileId = input.fileId; + } else { + throw new Error("Link target not found"); + } + + const [targetFile] = await ctx.db + .select() + .from(files) + .where( + and( + eq(files.id, fileId), + eq(files.workspaceId, link.workspaceId), + eq(files.status, "ready"), + ), + ); + if (!targetFile) throw new Error("File not found"); + + // For folder shares, verify the file lives inside the shared folder tree + if (link.folderId) { + if (!targetFile.folderId) throw new Error("File not found"); + const allowed = await isDescendantFolder( + ctx.db, + targetFile.folderId, + link.folderId, + ); + if (!allowed) throw new Error("File not found"); + } + + const storage = await createStorageForFile(targetFile.id); + const url = await storage.getSignedUrl( + await getFileStoragePath(targetFile.id), + 3600, + ); + return { url, filename: targetFile.name }; + }), + getDownloadUrl: publicProcedure .input( z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac42cdb..8230db3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz zod: specifier: ^4.3.6 version: 4.3.6 @@ -858,11 +861,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -3556,6 +3559,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -7375,6 +7379,12 @@ packages: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -15149,6 +15159,8 @@ snapshots: xdg-basedir@5.1.0: {} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xml2js@0.6.2: dependencies: sax: 1.6.0