diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb59b9a..c756908 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -75,6 +75,7 @@
**File & export**
- ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`)
- ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings
+- Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior)
- Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents
- PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog)
- Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact)
diff --git a/src/App.svelte b/src/App.svelte
index 3c21d85..6c93c93 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -335,6 +335,9 @@
if (!editor) return;
try {
const isDocx = sourceName?.toLowerCase().endsWith('.docx');
+ // An .ott is a template: load its content but don't bind the handle, so the
+ // first Save prompts for a new .odt instead of overwriting the template.
+ const isTemplate = sourceName?.toLowerCase().endsWith('.ott');
// Pre-decode any images in a format the browser can't render (TIFF, …) to PNG.
// Lazy: the decoder loads only when such an image is present, else this is a no-op.
const converted = await convertUnsupportedImages(bytes);
@@ -364,7 +367,7 @@
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
};
- fileHandle = handle;
+ fileHandle = isTemplate ? null : handle;
if (result.warnings.length) {
console.warn('[import] Unsupported content in opened file:', result.warnings);
@@ -779,7 +782,7 @@
diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts
index 3f3b4a5..33a00b3 100644
--- a/src/lib/export/saveFile.ts
+++ b/src/lib/export/saveFile.ts
@@ -2,6 +2,7 @@
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
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 = [
@@ -12,6 +13,12 @@ const DOCX_PICKER_TYPES = [
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
];
+// The open picker also accepts .ott templates (read-only; saving stays .odt-only).
+const OPEN_PICKER_TYPES = [
+ { description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } },
+ ...DOCX_PICKER_TYPES,
+];
+
// showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.
type WinFs = Window & {
showSaveFilePicker?: (opts: unknown) => Promise;
@@ -81,10 +88,10 @@ export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Prom
await writeHandle(handle, bytes);
}
-// Prompt for an .odt/.docx to open, capturing its handle so a later save can overwrite
-// the same file. Returns null if cancelled. Only call when supportsFsAccess().
+// 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: [...PICKER_TYPES, ...DOCX_PICKER_TYPES], multiple: false });
+ 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 };
diff --git a/src/lib/storage/documentName.ts b/src/lib/storage/documentName.ts
index 804986f..c2f7946 100644
--- a/src/lib/storage/documentName.ts
+++ b/src/lib/storage/documentName.ts
@@ -11,9 +11,9 @@ export function saveDocName(name: string): void {
localStorage.setItem(KEY, name);
}
-// Drop a trailing .odt (case-insensitive) so the field shows just the name.
+// Drop a trailing .odt or .ott (case-insensitive) so the field shows just the name.
export function stripOdtExtension(name: string): string {
- return name.replace(/\.odt$/i, '');
+ return name.replace(/\.o[dt]t$/i, '');
}
// Strip filesystem-illegal characters; keep spaces so user-typed titles read
diff --git a/tests/unit/strip-odt-extension.test.ts b/tests/unit/strip-odt-extension.test.ts
new file mode 100644
index 0000000..f7daf41
--- /dev/null
+++ b/tests/unit/strip-odt-extension.test.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest';
+import { stripOdtExtension } from '../../src/lib/storage/documentName';
+
+describe('stripOdtExtension', () => {
+ it('drops a trailing .odt or .ott (case-insensitive)', () => {
+ expect(stripOdtExtension('Foo.odt')).toBe('Foo');
+ expect(stripOdtExtension('Foo.ott')).toBe('Foo');
+ expect(stripOdtExtension('Report.ODT')).toBe('Report');
+ expect(stripOdtExtension('Report.OTT')).toBe('Report');
+ });
+
+ it('leaves other names and extensions untouched', () => {
+ expect(stripOdtExtension('Foo.docx')).toBe('Foo.docx');
+ expect(stripOdtExtension('my.odt.notes')).toBe('my.odt.notes');
+ expect(stripOdtExtension('plain')).toBe('plain');
+ });
+});