diff --git a/src/hooks/usePath.ts b/src/hooks/usePath.ts index d80b4684b9..2873dc2b3b 100644 --- a/src/hooks/usePath.ts +++ b/src/hooks/usePath.ts @@ -288,6 +288,7 @@ export const usePath = () => { (data) => { ObjStore.setObj(data) ObjStore.setProvider(data.provider) + ObjStore.setWebProxy(Boolean(data.web_proxy)) if (data.is_dir) { setPathAs(path) handleFolder(path, index, size) diff --git a/src/lang/en/home.json b/src/lang/en/home.json index 4a31bb0c11..fbaf844bd9 100644 --- a/src/lang/en/home.json +++ b/src/lang/en/home.json @@ -49,7 +49,42 @@ "tr-install": "TrollStore", "tr-installing": "TrollStore Installing", "open_in_new_window": "Open in new window", - "auto_next": "Auto next" + "auto_next": "Auto next", + "lark_preview": "Lark Preview", + "lark_tools": { + "title": "Lark Tools", + "unsupported_current": "Lark export is not supported for this file.", + "unsupported_title": "Export is not supported", + "unsupported_description": "The first version supports exporting Lark Docs to PDF/DOCX, and Sheets/Bitable to XLSX.", + "loading_options": "Loading export options", + "option_failed": "Failed to load export options", + "create_export_task": "Create export task", + "download_export": "Download {{name}}", + "download_name": "Output file: {{name}}", + "download_started": "The exported file download has started.", + "download_failed": "Failed to download the exported file.", + "export_failed": "Lark export task failed.", + "export_timeout": "Export timed out. Please try again later.", + "feishu_error_detail": "Feishu error detail", + "copy_error_detail": "Copy detail", + "sub_resource": "Sheet/Table", + "sub_resource_required": "Please select a sheet or table before exporting CSV.", + "sub_resource_error": "Failed to load sheets or tables", + "no_sub_resources": "No sheet or table available", + "type": { + "doc": "Lark Docs", + "docx": "Lark Docs", + "sheet": "Lark Sheets", + "bitable": "Lark Bitable" + }, + "status": { + "success_title": "Export completed", + "failed_title": "Export failed", + "processing_title": "Exporting", + "success_description": "The exported file is ready to download.", + "processing_description": "Waiting for Lark to finish the export task." + } + } }, "layouts": { "list": "List View", diff --git a/src/pages/home/file/File.tsx b/src/pages/home/file/File.tsx index f99337c8dc..a935a30c25 100644 --- a/src/pages/home/file/File.tsx +++ b/src/pages/home/file/File.tsx @@ -6,10 +6,16 @@ import { objStore } from "~/store" import { Download } from "../previews/download" import { OpenWith } from "./open-with" import { getPreviews } from "../previews" +import { useT } from "~/hooks" const File = () => { + const t = useT() const previews = createMemo(() => { - return getPreviews({ ...objStore.obj, provider: objStore.provider }) + return getPreviews({ + ...objStore.obj, + provider: objStore.provider, + web_proxy: objStore.web_proxy, + }) }) const [cur, setCur] = createSignal(previews()[0]) return ( @@ -22,7 +28,10 @@ const File = () => { onChange={(name) => { setCur(previews().find((p) => p.name === name)!) }} - options={previews().map((item) => ({ value: item.name }))} + options={previews().map((item) => ({ + value: item.name, + label: item.i18nKey ? t(item.i18nKey) : item.name, + }))} /> diff --git a/src/pages/home/previews/index.ts b/src/pages/home/previews/index.ts index 61282ea41d..eae320a673 100644 --- a/src/pages/home/previews/index.ts +++ b/src/pages/home/previews/index.ts @@ -36,16 +36,52 @@ const isPrior = (p: Prior): boolean => { export interface Preview { name: string + i18nKey?: string type?: ObjType exts?: Ext provider?: RegExp + enabled?: (file: PreviewFile) => boolean component: Component prior: Prior } -export type PreviewComponent = Pick +export type PreviewComponent = Pick +type PreviewFile = Obj & { + provider: string + download_url?: string + web_proxy?: boolean +} + +const larkCloudDocExts = [ + "lark-doc", + "lark-docx", + "lark-sheet", + "lark-bitable", + "lark-mindnote", + "lark-slides", +] + +const isLarkCloudDoc = (name: string) => + larkCloudDocExts.includes(ext(name).toLowerCase()) const previews: Preview[] = [ + { + name: "Lark Preview", + i18nKey: "home.preview.lark_preview", + exts: "*", + provider: /^Lark$/, + enabled: (file) => !file.web_proxy || isLarkCloudDoc(file.name), + component: lazy(() => import("./lark")), + prior: true, + }, + { + name: "Lark Tools", + i18nKey: "home.preview.lark_tools.title", + exts: ["lark-doc", "lark-docx", "lark-sheet", "lark-bitable"], + provider: /^Lark$/, + component: lazy(() => import("./lark_tools")), + prior: true, + }, { name: "HTML render", exts: ["html"], @@ -160,9 +196,7 @@ const previews: Preview[] = [ }, ] -export const getPreviews = ( - file: Obj & { provider: string; download_url?: string }, -): PreviewComponent[] => { +export const getPreviews = (file: PreviewFile): PreviewComponent[] => { const { pathname, searchParams } = useRouter() const typeOverride = ObjType[searchParams["type"]?.toUpperCase() as keyof typeof ObjType] @@ -177,12 +211,19 @@ export const getPreviews = ( if (preview.provider && !preview.provider.test(file.provider)) { return } + if (preview.enabled && !preview.enabled(file)) { + return + } if ( preview.type === file.type || (typeOverride && preview.type === typeOverride) || extsContains(preview.exts, file.name) ) { - const r = { name: preview.name, component: preview.component } + const r = { + name: preview.name, + i18nKey: preview.i18nKey, + component: preview.component, + } if (isPrior(preview.prior)) { res.push(r) } else { @@ -202,6 +243,7 @@ export const getPreviews = ( if (!isShareRoute || file.download_url) { res.push({ name: "Download", + i18nKey: "home.preview.download", component: lazy(() => import("./download")), }) } diff --git a/src/pages/home/previews/lark.tsx b/src/pages/home/previews/lark.tsx new file mode 100644 index 0000000000..1d4d9ac8d9 --- /dev/null +++ b/src/pages/home/previews/lark.tsx @@ -0,0 +1,33 @@ +import { Button, HStack, hope } from "@hope-ui/solid" +import { TbExternalLink } from "solid-icons/tb" +import { BoxWithFullScreen } from "~/components" +import { objStore } from "~/store" +import { useT } from "~/hooks" + +const LarkPreview = () => { + const t = useT() + return ( + + + + + + + ) +} + +export default LarkPreview diff --git a/src/pages/home/previews/lark_tools.tsx b/src/pages/home/previews/lark_tools.tsx new file mode 100644 index 0000000000..4710798e6b --- /dev/null +++ b/src/pages/home/previews/lark_tools.tsx @@ -0,0 +1,582 @@ +import { + Alert, + AlertDescription, + AlertTitle, + Box, + Button, + HStack, + Text, + VStack, +} from "@hope-ui/solid" +import { + createEffect, + createMemo, + createSignal, + onCleanup, + Show, +} from "solid-js" +import { SelectWrapper } from "~/components" +import { useFetch, useRouter, useT, useUtil } from "~/hooks" +import { objStore, password } from "~/store" +import { PResp } from "~/types" +import { handleResp, notify, r } from "~/utils" + +type ExportFormat = "pdf" | "docx" | "xlsx" | "csv" +type ExportStatus = "idle" | "pending" | "processing" | "success" | "failed" + +type LarkExportCreateResp = { + ticket: string + token: string + type: string + format: ExportFormat + sub_id?: string +} + +type LarkExportFormatOption = { + value: ExportFormat + label: string + requires_sub_id?: boolean +} + +type LarkExportSubResource = { + id: string + name: string + type: string +} + +type LarkExportOptionsResp = { + type: string + formats: LarkExportFormatOption[] + sub_resources?: LarkExportSubResource[] + sub_resource_error?: string +} + +type LarkExportStatusResp = { + status: Exclude + file_token?: string + file_size?: number + job_status?: number + error_message?: string + error_detail?: string +} + +type StoredExportTask = { + ticket: string + format: ExportFormat + sub_id?: string + status: ExportStatus + file_token?: string + error_message?: string + error_detail?: string + updated_at: number +} + +const larkSuffixes = [".lark-doc", ".lark-docx", ".lark-sheet", ".lark-bitable"] + +const larkBaseName = (name: string) => { + for (const suffix of larkSuffixes) { + if (name.endsWith(suffix)) { + return name.slice(0, -suffix.length) + } + } + return name +} + +const storageAvailable = () => + typeof window !== "undefined" && window.sessionStorage + +const LarkTools = () => { + const { pathname } = useRouter() + const t = useT() + const { copy } = useUtil() + const [exportOptions, setExportOptions] = + createSignal() + const [optionError, setOptionError] = createSignal("") + const [format, setFormat] = createSignal("pdf") + const [subResourceID, setSubResourceID] = createSignal("") + const [ticket, setTicket] = createSignal("") + const [status, setStatus] = createSignal("idle") + const [fileToken, setFileToken] = createSignal("") + const [errorMessage, setErrorMessage] = createSignal("") + const [errorDetail, setErrorDetail] = createSignal("") + let timer: number | undefined + let attempts = 0 + + const taskKey = (path = pathname(), name = objStore.obj.name) => + `alist:lark-export:${path}:${name}` + + const selectedFormat = createMemo( + () => exportOptions()?.formats.find((item) => item.value === format()), + ) + const requiresSubResource = createMemo( + () => selectedFormat()?.requires_sub_id ?? false, + ) + const subResources = createMemo(() => exportOptions()?.sub_resources ?? []) + const canCreate = createMemo( + () => + Boolean(exportOptions()?.formats.length) && + (!requiresSubResource() || Boolean(subResourceID())), + ) + + const stopPolling = () => { + if (timer !== undefined) { + window.clearTimeout(timer) + timer = undefined + } + } + + onCleanup(stopPolling) + + const saveTask = (task: StoredExportTask) => { + const storage = storageAvailable() + if (!storage || !task.ticket) return + storage.setItem(taskKey(), JSON.stringify(task)) + } + + const saveCurrentTask = (next: Partial = {}) => { + if (!ticket() && !next.ticket) return + saveTask({ + ticket: next.ticket ?? ticket(), + format: next.format ?? format(), + sub_id: next.sub_id ?? subResourceID(), + status: next.status ?? status(), + file_token: next.file_token ?? fileToken(), + error_message: next.error_message ?? errorMessage(), + error_detail: next.error_detail ?? errorDetail(), + updated_at: Date.now(), + }) + } + + const restoreTask = ( + path: string, + name: string, + options: LarkExportOptionsResp, + ) => { + const storage = storageAvailable() + if (!storage) return + const raw = storage.getItem(taskKey(path, name)) + if (!raw) return + let task: StoredExportTask + try { + task = JSON.parse(raw) + } catch { + storage.removeItem(taskKey(path, name)) + return + } + if ( + !task.ticket || + !options.formats.some((item) => item.value === task.format) + ) { + storage.removeItem(taskKey(path, name)) + return + } + setTicket(task.ticket) + setFormat(task.format) + setSubResourceID(task.sub_id ?? "") + setStatus(task.status) + setFileToken(task.file_token ?? "") + setErrorMessage(task.error_message ?? "") + setErrorDetail(task.error_detail ?? "") + if (task.status === "pending" || task.status === "processing") { + setStatus("processing") + timer = window.setTimeout(poll, 500) + } + } + + const [loadingOptions, fetchExportOptions] = useFetch( + (): PResp => + r.post("/fs/other", { + path: pathname(), + password: password(), + method: "lark_export_options", + data: {}, + }), + true, + ) + + const [creating, createExport] = useFetch( + (): PResp => + r.post("/fs/other", { + path: pathname(), + password: password(), + method: "lark_export_create", + data: { + format: format(), + sub_id: subResourceID(), + }, + }), + ) + + const [checking, checkExport] = useFetch( + (): PResp => + r.post("/fs/other", { + path: pathname(), + password: password(), + method: "lark_export_status", + data: { + ticket: ticket(), + }, + }), + ) + + const loadExportOptions = async (path: string, name: string) => { + stopPolling() + attempts = 0 + setExportOptions(undefined) + setOptionError("") + setTicket("") + setFileToken("") + setErrorMessage("") + setErrorDetail("") + setStatus("idle") + const resp = await fetchExportOptions() + handleResp( + resp, + (data) => { + setExportOptions(data) + const firstFormat = data.formats[0]?.value + if (firstFormat) { + setFormat(firstFormat) + } + setSubResourceID(data.sub_resources?.[0]?.id ?? "") + if (data.sub_resource_error) { + setOptionError(data.sub_resource_error) + } + restoreTask(path, name, data) + }, + (msg) => { + setOptionError(msg) + }, + true, + false, + ) + } + + createEffect(() => { + void loadExportOptions(pathname(), objStore.obj.name) + }) + + createEffect(() => { + const options = exportOptions()?.formats + if (options?.length && !options.some((item) => item.value === format())) { + setFormat(options[0].value) + } + if (!requiresSubResource()) { + setSubResourceID("") + return + } + if (!subResourceID() && subResources()[0]?.id) { + setSubResourceID(subResources()[0].id) + } + }) + + const poll = async () => { + if (!ticket()) return + const resp = await checkExport() + handleResp( + resp, + (data) => { + setStatus(data.status) + if (data.error_message) { + setErrorMessage(data.error_message) + } + if (data.error_detail) { + setErrorDetail(data.error_detail) + } + if (data.status === "success" && data.file_token) { + setFileToken(data.file_token) + saveCurrentTask({ + status: "success", + file_token: data.file_token, + error_message: "", + error_detail: "", + }) + stopPolling() + return + } + if (data.status === "failed") { + saveCurrentTask({ + status: "failed", + error_message: data.error_message ?? errorMessage(), + error_detail: data.error_detail ?? errorDetail(), + }) + stopPolling() + return + } + attempts += 1 + if (attempts >= 30) { + const msg = t("home.preview.lark_tools.export_timeout") + setStatus("failed") + setErrorMessage(msg) + setErrorDetail("") + saveCurrentTask({ + status: "failed", + error_message: msg, + error_detail: "", + }) + stopPolling() + return + } + saveCurrentTask({ status: "processing" }) + timer = window.setTimeout(poll, 2000) + }, + (msg) => { + setStatus("failed") + setErrorMessage(msg) + setErrorDetail(msg) + saveCurrentTask({ + status: "failed", + error_message: msg, + error_detail: msg, + }) + stopPolling() + }, + true, + false, + ) + } + + const startExport = async () => { + if (requiresSubResource() && !subResourceID()) { + notify.warning(t("home.preview.lark_tools.sub_resource_required")) + return + } + stopPolling() + attempts = 0 + setTicket("") + setFileToken("") + setErrorMessage("") + setErrorDetail("") + setStatus("pending") + const resp = await createExport() + handleResp( + resp, + (data) => { + setTicket(data.ticket) + setStatus("processing") + saveTask({ + ticket: data.ticket, + format: data.format, + sub_id: data.sub_id ?? subResourceID(), + status: "processing", + updated_at: Date.now(), + }) + timer = window.setTimeout(poll, 1000) + }, + (msg) => { + setStatus("failed") + setErrorMessage(msg) + setErrorDetail(msg) + saveCurrentTask({ + status: "failed", + error_message: msg, + error_detail: msg, + }) + }, + ) + } + + const downloadName = () => `${larkBaseName(objStore.obj.name)}.${format()}` + + const downloadExport = async () => { + if (!fileToken()) return + const query = new URLSearchParams({ + path: pathname(), + password: password(), + file_token: fileToken(), + filename: downloadName(), + }) + const blob = await r.get(`/fs/lark/export/download?${query.toString()}`, { + responseType: "blob", + }) + if (!(blob instanceof Blob)) { + notify.error( + blob?.message ?? t("home.preview.lark_tools.download_failed"), + ) + return + } + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = downloadName() + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + notify.success(t("home.preview.lark_tools.download_started")) + } + + return ( + + + + + + {t("home.preview.lark_tools.title")} + + + {loadingOptions() + ? t("home.preview.lark_tools.loading_options") + : exportOptions() + ? t(`home.preview.lark_tools.type.${exportOptions()!.type}`) + : t("home.preview.lark_tools.unsupported_current")} + + + setFormat(value)} + options={ + exportOptions()?.formats.map((item) => ({ + value: item.value, + label: item.label, + })) ?? [{ value: format(), label: format().toUpperCase() }] + } + size="sm" + w="120px" + /> + + + + + + {exportOptions() + ? t("home.preview.lark_tools.sub_resource_error") + : t("home.preview.lark_tools.option_failed")} + + {optionError()} + + + + + + {t("home.preview.lark_tools.unsupported_title")} + + + {t("home.preview.lark_tools.unsupported_description")} + + + } + > + + + + {t("home.preview.lark_tools.sub_resource")} + + setSubResourceID(value)} + options={ + subResources().length + ? subResources().map((item) => ({ + value: item.id, + label: item.name, + })) + : [ + { + value: "", + label: t("home.preview.lark_tools.no_sub_resources"), + }, + ] + } + size="sm" + w="$full" + /> + + + + + {t("home.preview.lark_tools.download_name", { + name: downloadName(), + })} + + + + + + + + + + + + + {status() === "success" + ? t("home.preview.lark_tools.status.success_title") + : status() === "failed" + ? t("home.preview.lark_tools.status.failed_title") + : t("home.preview.lark_tools.status.processing_title")} + + + {status() === "failed" ? ( + + + {errorMessage() || + t("home.preview.lark_tools.export_failed")} + + + + + {t("home.preview.lark_tools.feishu_error_detail")} + + + + + {errorDetail()} + + + + ) : status() === "success" ? ( + t("home.preview.lark_tools.status.success_description") + ) : ( + t("home.preview.lark_tools.status.processing_description") + )} + + + + + + + ) +} + +export default LarkTools diff --git a/src/pages/home/previews/pdf.tsx b/src/pages/home/previews/pdf.tsx index e0ea2f997f..37b0fe68ee 100644 --- a/src/pages/home/previews/pdf.tsx +++ b/src/pages/home/previews/pdf.tsx @@ -1,8 +1,19 @@ import { hope } from "@hope-ui/solid" +import { createMemo } from "solid-js" import { BoxWithFullScreen } from "~/components" +import { useLink } from "~/hooks" import { objStore } from "~/store" const PdfPreview = () => { + const { proxyLink } = useLink() + const previewUrl = createMemo(() => { + if (!objStore.web_proxy) { + return objStore.raw_url + } + const url = new URL(proxyLink(objStore.obj, true), location.origin) + url.searchParams.set("type", "preview") + return url.toString() + }) return ( { h="$full" rounded="$lg" shadow="$md" - src={objStore.raw_url} + src={previewUrl()} title="PDF Preview" /> diff --git a/src/pages/share/index.tsx b/src/pages/share/index.tsx index 9f36b3bb78..5b07b89a34 100644 --- a/src/pages/share/index.tsx +++ b/src/pages/share/index.tsx @@ -539,6 +539,7 @@ const ShareFile = () => { }} options={previews().map((preview) => ({ value: preview.name, + label: preview.i18nKey ? t(preview.i18nKey) : preview.name, }))} /> diff --git a/src/store/obj.ts b/src/store/obj.ts index 8c52997878..4ce21d60a7 100644 --- a/src/store/obj.ts +++ b/src/store/obj.ts @@ -30,6 +30,7 @@ const [objStore, setObjStore] = createStore<{ readme: string header: string provider: string + web_proxy: boolean // pageIndex: number; // pageSize: number; state: State @@ -45,6 +46,7 @@ const [objStore, setObjStore] = createStore<{ readme: "", header: "", provider: "", + web_proxy: false, // pageIndex: 1, // pageSize: 50, state: State.Initial, @@ -72,6 +74,9 @@ export const ObjStore = { setProvider: (provider: string) => { setObjStore("provider", provider) }, + setWebProxy: (web_proxy: boolean) => { + setObjStore("web_proxy", web_proxy) + }, setObjs: setObjs, setTotal: (total: number) => { setObjStore("total", total) diff --git a/src/types/resp.ts b/src/types/resp.ts index 3698a5166d..661528c834 100644 --- a/src/types/resp.ts +++ b/src/types/resp.ts @@ -42,6 +42,7 @@ export type FsGetResp = Resp< readme: string header: string provider: string + web_proxy?: boolean related: Obj[] } >