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 (
+
+
+ }
+ >
+ {t("home.preview.open_in_new_window")}
+
+
+
+
+ )
+}
+
+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[]
}
>