Files
edentext/src/lib/export/saveFile.ts
T
Steffen Becker fc20e0a89c fix: improve saving where the File System Access API is unavailable
The fallback download shows a one-time hint about the browser's
ask-where-to-save setting, keeps its anchor in the DOM and the blob
URL alive past the click (Gecko). Failed saves and exports now report
the underlying error, with a clear message when a script blocker
prevented a lazy chunk from loading. A full or blocked localStorage
no longer fails the save when recording the recent-files entry.
2026-08-18 19:56:32 +02:00

150 lines
6.0 KiB
TypeScript

// File System Access API helpers for saving/opening .odt files. Falls back to a
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
import { t } from '../i18n/i18n.svelte';
// Shown once per browser, at the first save that goes the download route — the
// moment the missing save dialog is actually felt.
const HINT_KEY = 'edentext-download-hint';
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const OTT_MIME = 'application/vnd.oasis.opendocument.text-template';
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const PICKER_TYPES = [
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } },
];
const DOCX_PICKER_TYPES = [
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
];
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
// Both template formats in one picker: the chosen extension decides which is written.
const TEMPLATE_PICKER_TYPES = [
{ description: 'OpenDocument Text Template', accept: { [OTT_MIME]: ['.ott'] } },
{ description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } },
];
// The open picker also accepts templates (read-only; saving stays .odt-only).
const OPEN_PICKER_TYPES = [
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } },
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'], [DOTX_MIME]: ['.dotx'] } },
];
// showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.
type WinFs = Window & {
showSaveFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle>;
showOpenFilePicker?: (opts: unknown) => Promise<FileSystemFileHandle[]>;
};
export function supportsFsAccess(): boolean {
return typeof (window as WinFs).showSaveFilePicker === 'function';
}
// Where the browser saves is the browser's business (its download folder, or its own
// dialog where that is switched on). Gecko needs the anchor in the document and the
// blob URL alive past the click.
function download(bytes: Uint8Array, name: string, mime: string = ODT_MIME): void {
// Before the click: the browser's own save dialog would open on top of it.
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, '1');
alert(t().ribbon.saveAsHint);
}
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
async function writeHandle(handle: FileSystemFileHandle, bytes: Uint8Array): Promise<void> {
const writable = await handle.createWritable();
await writable.write(bytes as Uint8Array<ArrayBuffer>);
await writable.close();
}
// Save to the given handle if we have one; otherwise prompt for a location (i.e.
// the first save acts as Save As). Returns the handle written to, or null when
// falling back to a plain download. Throws AbortError if the user cancels.
export async function saveOdt(
bytes: Uint8Array,
suggestedName: string,
handle: FileSystemFileHandle | null,
): Promise<FileSystemFileHandle | null> {
if (!supportsFsAccess()) {
download(bytes, suggestedName);
return null;
}
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES }));
await writeHandle(target, bytes);
return target;
}
// Always prompt for a location. Returns the new handle, or null on fallback.
export async function saveAsOdt(
bytes: Uint8Array,
suggestedName: string,
): Promise<FileSystemFileHandle | null> {
if (!supportsFsAccess()) {
download(bytes, suggestedName);
return null;
}
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES });
await writeHandle(handle, bytes);
return handle;
}
// Save to the given handle if we have one; otherwise prompt for a location. Mirrors
// saveOdt, for a document that was opened as .docx and must round-trip as .docx.
export async function saveDocx(
bytes: Uint8Array,
suggestedName: string,
handle: FileSystemFileHandle | null,
): Promise<FileSystemFileHandle | null> {
if (!supportsFsAccess()) {
download(bytes, suggestedName, DOCX_MIME);
return null;
}
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCX_PICKER_TYPES }));
await writeHandle(target, bytes);
return target;
}
// Export a .docx: always prompt for a location (no handle is tracked — this is the
// explicit "Export" action, like PDF). Returns null. Throws AbortError if cancelled.
export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Promise<void> {
await saveDocx(bytes, suggestedName, null);
}
// Save a template. The picker offers both formats, so the bytes can only be built
// once the user has picked one: `build` is called with the chosen extension. Falls
// back to a plain .ott download where there is no picker.
export async function saveAsTemplate(
build: (kind: 'ott' | 'dotx') => Promise<Uint8Array>,
baseName: string,
): Promise<void> {
if (!supportsFsAccess()) {
download(await build('ott'), `${baseName}.ott`, OTT_MIME);
return;
}
const handle = await (window as WinFs).showSaveFilePicker!({
suggestedName: `${baseName}.ott`, types: TEMPLATE_PICKER_TYPES,
});
await writeHandle(handle, await build(handle.name.toLowerCase().endsWith('.dotx') ? 'dotx' : 'ott'));
}
// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can
// overwrite the same file. Returns null if cancelled. Only call when supportsFsAccess().
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
const [handle] = await (window as WinFs).showOpenFilePicker!({ types: OPEN_PICKER_TYPES, multiple: false });
if (!handle) return null;
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
}