ultisuite-client/lib/api/drive-download.ts
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

216 lines
6.7 KiB
TypeScript

import { apiClient } from "@/lib/api/client"
import { displayFileName, fileNameExtension } from "@/lib/drive/display-file-name"
import { decodePathSegment } from "@/lib/drive/drive-url"
const EXT_MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
bmp: "image/bmp",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
mkv: "video/x-matroska",
ogv: "video/ogg",
m4v: "video/mp4",
pdf: "application/pdf",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
aac: "audio/aac",
opus: "audio/opus",
weba: "audio/webm",
aiff: "audio/aiff",
mid: "audio/midi",
midi: "audio/midi",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
apng: "image/apng",
jfif: "image/jpeg",
}
function mimeFromFileName(name: string): string | undefined {
const ext = name.split(".").pop()?.toLowerCase() ?? ""
return EXT_MIME[ext]
}
const AMBIGUOUS_SERVER_MIMES = new Set([
"application/octet-stream",
"binary/octet-stream",
"text/plain",
"text/xml",
"application/xml",
])
function normalizeMime(value: string): string {
return value.toLowerCase().split(";")[0]?.trim() ?? ""
}
/** Pick MIME browsers accept for <img>/<video>/<audio> from server hints + extension. */
export function resolvePreviewMime(blobType: string, mimeType: string, fileName: string): string {
const fromExt = mimeFromFileName(fileName)
const blob = normalizeMime(blobType)
const hinted = normalizeMime(mimeType)
if (
fromExt === "image/svg+xml" ||
hinted.includes("svg") ||
blob.includes("svg") ||
fileName.toLowerCase().endsWith(".svg")
) {
return "image/svg+xml"
}
if (fromExt && (AMBIGUOUS_SERVER_MIMES.has(blob) || AMBIGUOUS_SERVER_MIMES.has(hinted) || !blob)) {
return fromExt
}
if (blob && !AMBIGUOUS_SERVER_MIMES.has(blob)) return blob
if (hinted && !AMBIGUOUS_SERVER_MIMES.has(hinted)) return hinted
return fromExt ?? blob ?? hinted ?? "application/octet-stream"
}
/** Nextcloud often returns application/octet-stream — browsers need a concrete type for <img>/<video>. */
export function blobForPreview(blob: Blob, mimeType: string, fileName: string): Blob {
const type = resolvePreviewMime(blob.type, mimeType, fileName)
if (!type || blob.type === type) return blob
return new Blob([blob], { type })
}
export async function fetchDrivePreviewBlob(file: {
path: string
name: string
mime_type: string
}): Promise<Blob> {
const raw = await apiClient.getBlob(driveDownloadApiPath(logicalDriveFilePath(file)))
return blobForPreview(raw, file.mime_type, file.name)
}
/** Logical client path for download (decode then re-encode; join name if path is parent dir). */
export function logicalDriveFilePath(file: { path: string; name: string }): string {
const decodeSeg = (seg: string) => {
try {
return decodeURIComponent(seg)
} catch {
return seg
}
}
let p = file.path.trim()
if (!p.startsWith("/")) p = `/${p}`
const segments = p.split("/").filter(Boolean).map(decodeSeg)
p = segments.length ? `/${segments.join("/")}` : "/"
const name = displayFileName(file.name)
if (!name) return p.replace(/\/+/g, "/")
const base = segments[segments.length - 1] ?? ""
const baseDecoded = displayFileName(base)
if (baseDecoded === name) return p.replace(/\/+/g, "/")
// WebDAV href basename is canonical; don't append a diverging display name.
const ext = fileNameExtension(name)
if (ext && baseDecoded.toLowerCase().endsWith(`.${ext}`)) {
return p.replace(/\/+/g, "/")
}
return `${p.replace(/\/$/, "")}/${name}`.replace(/\/+/g, "/")
}
/** API path for GET /drive/filter-corpus/* (recursive file index for client filters). */
export function driveFilterCorpusApiPath(folderPath: string): string {
if (folderPath === "/" || folderPath.trim() === "") return "/drive/filter-corpus"
const parts = folderPath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(decodePathSegment(seg)))
return `/drive/filter-corpus/${parts.join("/")}`
}
/** API path for GET /drive/files/* (segments URL-encoded). */
export function driveFilesListApiPath(folderPath: string): string {
if (folderPath === "/" || folderPath.trim() === "") return "/drive/files/"
const parts = folderPath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(decodePathSegment(seg)))
return `/drive/files/${parts.join("/")}`
}
/** API path for GET /drive/preview/* (segments URL-encoded). */
export function drivePreviewApiPath(filePath: string, width = 400, height = 300): string {
const parts = filePath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
return `/drive/preview/${parts.join("/")}?w=${width}&h=${height}`
}
export async function fetchDriveServerPreview(
file: { path: string; name: string },
width = 400,
height = 300
): Promise<Blob> {
const logical = logicalDriveFilePath(file)
return apiClient.getBlob(drivePreviewApiPath(logical, width, height))
}
/** API path for GET /drive/download/* (segments URL-encoded). */
export function driveDownloadApiPath(filePath: string): string {
const parts = filePath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
return `/drive/download/${parts.join("/")}`
}
export async function downloadDriveFile(
filePath: string,
suggestedName?: string,
fileNameForPath?: string
) {
const logical =
fileNameForPath != null
? logicalDriveFilePath({ path: filePath, name: fileNameForPath })
: filePath
const blob = await apiClient.getBlob(driveDownloadApiPath(logical))
const name =
displayFileName(suggestedName ?? filePath.split("/").filter(Boolean).pop() ?? "download")
const url = URL.createObjectURL(blob)
try {
const anchor = document.createElement("a")
anchor.href = url
anchor.download = name
anchor.rel = "noopener"
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
} finally {
URL.revokeObjectURL(url)
}
}
/** Open file in a new tab (preview) with Bearer auth — avoids raw API URL in the browser. */
export async function openDriveFileInNewTab(filePath: string) {
const blob = await apiClient.getBlob(driveDownloadApiPath(filePath))
const url = URL.createObjectURL(blob)
const opened = window.open(url, "_blank", "noopener,noreferrer")
if (!opened) {
URL.revokeObjectURL(url)
await downloadDriveFile(filePath)
return
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
}