Ver Fonte

Add foreign-format save support to Office Sheets

Introduce `saveFormats` support in the Office framework so apps can save back to non-native files (including keeping imported files attached for Ctrl+S), with format-aware Save As entries, one-way format handling (PDF), and autosave-safe veto behavior when a format cannot preserve content. Implement Sheets save-back writers for `.xlsx`, `.ods`, `.csv`, `.tsv`, and `.pdf`, add capability checks that block lossy saves with clear reasons, and refactor delimited output for both export/download and VFS save. Update Office/contract docs to define the new behavior and fix Sheets selection-border move cursor styling/hover state.
Toby Chui há 4 dias atrás
pai
commit
f30bcf5fc1

+ 17 - 0
src/web/Office/README.md

@@ -113,6 +113,23 @@ body before posting:
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   Latin-1 and have no emoji glyphs.
   Latin-1 and have no emoji glyphs.
 
 
+### Saving back into a foreign format
+
+Sheets declares `saveFormats` (see
+[`common/CONTRACT.md`](common/CONTRACT.md)), so a workbook opened from
+`.xlsx` / `.ods` / `.csv` / `.tsv` **stays that file**: `Ctrl+S` rewrites it
+in its own format instead of forcing a Save As to `.xlsa`, and File > Save as
+offers the whole list (plus PDF, which is one-way).
+
+Each format vetoes what it cannot hold — `.csv`/`.tsv` reject formulas,
+charts, notes, merges and second sheets; `.ods` rejects charts
+(`ods_writer.go` cannot represent them); `.xlsx` takes everything. The veto
+lists what would be lost and offers `.xlsa` instead, so no save quietly drops
+content. Purely visual formatting is deliberately *not* a veto reason: it
+would fire on nearly every CSV edit. When a format's Go writer gains or loses
+a capability, update the matching `unsupported()` in
+[`sheets/sheets_io.js`](sheets/sheets_io.js).
+
 All that pre-baking makes the export payload big, and the AGI gateway reads
 All that pre-baking makes the export payload big, and the AGI gateway reads
 its POST parameters with Go's `r.ParseForm`, which **drops every parameter
 its POST parameters with Go's `r.ParseForm`, which **drops every parameter
 once a urlencoded body passes 10 MB** (the connection is then reset
 once a urlencoded body passes 10 MB** (the connection is then reset

+ 46 - 0
src/web/Office/common/CONTRACT.md

@@ -94,6 +94,9 @@ OfficeApp.init({
         ".pptx": function(filepath, filename){ … }
         ".pptx": function(filepath, filename){ … }
     },
     },
 
 
+    // --- foreign-format saving (optional) — see "Save formats" below ---
+    saveFormats: [ { ext, label, icon, oneWay, unsupported, save }, … ],
+
     // --- undo/redo (recommended: use OfficeUndoStack) ---
     // --- undo/redo (recommended: use OfficeUndoStack) ---
     onUndo: function(){ undo.undo(); },
     onUndo: function(){ undo.undo(); },
     onRedo: function(){ undo.redo(); },
     onRedo: function(){ undo.redo(); },
@@ -234,6 +237,49 @@ undo.undo(); undo.redo(); undo.canUndo(); undo.canRedo();
 Your app owns only `body`. **Document your body schema in a comment at the top
 Your app owns only `body`. **Document your body schema in a comment at the top
 of your app.js** so the other apps / future importers can read it.
 of your app.js** so the other apps / future importers can read it.
 
 
+## Save formats (`saveFormats`) — living in a foreign file
+
+By default a document can only be *saved* into the app's own container; a
+`.csv` or `.xlsx` you opened was an import, and Save became Save As. Declaring
+`saveFormats` lets an app write other formats too:
+
+```js
+saveFormats: [{
+    ext: ".csv",
+    label: "CSV (.csv)",              // shown in the Save as submenu
+    icon: "file alternate outline",   // semantic icon name
+    oneWay: true,                     // optional; a rendering such as PDF
+    unsupported: function(){ return ["2 charts"]; },   // null/[] = fine
+    save: function(fp, fn, done, fail){ … }            // fail(msg) on error
+}]
+```
+
+What the framework then does:
+
+- **File > Save as** turns into a format picker — the native container first
+  (still `Ctrl+Shift+S`), then one entry per format. With no `saveFormats` it
+  stays the plain "Save as..." command it has always been.
+- **Opening one of these formats keeps the document attached to that file**:
+  `filepath`/`filename` stay the original (`sales.csv`, not `sales.xlsa`), so
+  `Ctrl+S` writes straight back in the same format. An imported format with
+  **no** matching entry is read-only as before — `filepath` is null and Save
+  falls through to Save As.
+- **`unsupported()` is a veto, not a warning.** A foreign format holds less
+  than the container does, so return a list of plain-string reasons ("2
+  charts", "3 sheets — a delimited text file holds only one") when the
+  document would lose content. The framework refuses the write and offers
+  "Save as `<native ext>`..." instead. Reasons are escaped, never treated as
+  markup. Return `null`/`[]` when the format fits. Keep purely cosmetic
+  losses (fonts, colors, column widths) *out* of the list — vetoing on those
+  nags on every save.
+- **`oneWay: true`** marks a rendering (PDF): it is written, but the document
+  keeps its own path and stays dirty, because you cannot reopen it.
+- **Autosave** writes a foreign format only while `unsupported()` passes; when
+  it does not, autosave silently skips the file and falls back to the session
+  snapshot rather than popping a dialog.
+
+Sheets is the reference implementation (`sheets/sheets_io.js`, `SAVE_FORMATS`).
+
 ## Packed native files (zip container)
 ## Packed native files (zip container)
 
 
 All three apps set `packed: true` in `OfficeApp.init`. Native files
 All three apps set `packed: true` in `OfficeApp.init`. Native files

+ 178 - 17
src/web/Office/common/office.js

@@ -510,17 +510,38 @@ var OfficeApp = (function () {
     function loadNativeText(text, fp, fn) {
     function loadNativeText(text, fp, fn) {
         loadNativeEnvelope(parseEnvelope(text), fp, fn);
         loadNativeEnvelope(parseEnvelope(text), fp, fn);
     }
     }
+    /*
+        A file opened from a foreign format stays attached to that file when
+        the app declared a writer for its extension (cfg.saveFormats): Save
+        then writes back in the original format, and only steps up to the
+        native container once the document outgrows it (see doSaveForeign).
+        With no writer the import is read-only and Save has to become Save As
+        - which is what every app did before saveFormats existed.
+    */
+    function adoptImportedPath(fp, fn) {
+        loadedFromImport = true;
+        if (saveFormatFor(extOf(fn))) {
+            filepath = fp;
+            filename = fn;
+        } else {
+            filepath = null;   // read-only import: force Save As on save
+            filename = stripExt(fn) + cfg.extension;
+        }
+    }
+    function importedStatus(fn) {
+        return filepath ? ("Opened " + fn) :
+            ("Imported " + fn + " - use Save to store it as " + cfg.extension);
+    }
     function loadImportText(text, fp, fn) {
     function loadImportText(text, fp, fn) {
         var ext = extOf(fn);
         var ext = extOf(fn);
         var importer = (cfg.importers || {})[ext];
         var importer = (cfg.importers || {})[ext];
         if (!importer) { setStatus("Unsupported file type " + ext, "error"); return; }
         if (!importer) { setStatus("Unsupported file type " + ext, "error"); return; }
         meta = { createdAt: now(), revision: 0 };
         meta = { createdAt: now(), revision: 0 };
-        filepath = null;   // imported: force Save As on save
-        filename = stripExt(fn) + cfg.extension;
-        loadedFromImport = true;
+        adoptImportedPath(fp, fn);
         importer(text, fn);
         importer(text, fn);
         dirty = false; updateTitle();
         dirty = false; updateTitle();
-        setStatus("Imported " + fn + " - use Save to store it as " + cfg.extension);
+        if (filepath) addRecent(filepath, filename);
+        setStatus(importedStatus(fn));
     }
     }
     function openPath(fp, fn) {
     function openPath(fp, fn) {
         fn = fn || basename(fp);
         fn = fn || basename(fp);
@@ -529,11 +550,10 @@ var OfficeApp = (function () {
         var bi = (cfg.binaryImporters || {})[extOf(fn)];
         var bi = (cfg.binaryImporters || {})[extOf(fn)];
         if (bi) {
         if (bi) {
             meta = { createdAt: now(), revision: 0 };
             meta = { createdAt: now(), revision: 0 };
-            filepath = null;   // imported: force Save As on save
-            filename = stripExt(fn) + cfg.extension;
-            loadedFromImport = true;
+            adoptImportedPath(fp, fn);
             dirty = false;
             dirty = false;
             updateTitle();
             updateTitle();
+            if (filepath) addRecent(filepath, filename);
             bi(fp, fn);
             bi(fp, fn);
             return;
             return;
         }
         }
@@ -611,20 +631,119 @@ var OfficeApp = (function () {
                 "Discard", "Cancel", function (yes) { if (yes) go(); });
                 "Discard", "Cancel", function (yes) { if (yes) go(); });
         } else { go(); }
         } else { go(); }
     }
     }
+    /* ---------- foreign save formats (cfg.saveFormats) ---------- */
+    /*
+        An app may declare formats it can write besides its native container:
+
+            saveFormats: [{
+                ext: ".csv", label: "CSV (.csv)", icon: "file alternate outline",
+                oneWay: true,                        // a rendering (PDF):
+                                                     // never becomes the file
+                                                     // the document lives in
+                unsupported: fn -> null | [reason, ...],
+                save: fn(fp, fn, done, fail(msg))
+            }]
+
+        They drive both Save As (a format picker) and Save (a document opened
+        from one of these formats writes straight back to it). Apps that
+        declare none keep the native-format-only behaviour unchanged.
+    */
+    function saveFormats() { return (cfg && cfg.saveFormats) || []; }
+    function findSaveFormat(ext, adoptableOnly) {
+        var list = saveFormats();
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].ext !== ext) continue;
+            return (adoptableOnly && list[i].oneWay) ? null : list[i];
+        }
+        return null;
+    }
+    // a writer whose output the document can go on living in (excludes PDF)
+    function saveFormatFor(ext) { return findSaveFormat(ext, true); }
+    function formatLabel(fmt) { return (fmt && (fmt.label || fmt.ext)) || cfg.extension; }
+    function formatReasons(fmt) {
+        if (!fmt || !fmt.unsupported) return null;
+        var r;
+        try { r = fmt.unsupported(); } catch (e) { return null; }
+        return (r && r.length) ? r : null;
+    }
+    /*
+        A foreign format holds less than the native container does, so it may
+        refuse a document outright instead of silently dropping content. The
+        reasons come from the app as plain strings - escaped here, never
+        trusted as markup.
+    */
+    function formatBlockedDialog(fmt, reasons) {
+        var label = formatLabel(fmt);
+        var list = reasons.map(function (t) {
+            return "<li>" + escapeHtml(t) + "</li>";
+        }).join("");
+        setStatus("Cannot save in " + label + " format", "error");
+        dialog({
+            title: "Cannot save as " + label,
+            body: "<p>This " + escapeHtml(cfg.fileTypeName.toLowerCase()) +
+                " uses features that " + escapeHtml(label) + " cannot store:</p>" +
+                "<ul>" + list + "</ul>" +
+                "<p>Save it as " + escapeHtml(cfg.extension) +
+                " to keep them, or use File &gt; Export for a lossy copy.</p>",
+            buttons: [
+                { label: "Cancel" },
+                {
+                    label: "Save as " + cfg.extension + "...", primary: true,
+                    action: function (close) { close(); saveAsFormat(null); }
+                }
+            ]
+        });
+    }
+    function doSaveForeign(fp, fn, fmt, cb, silent) {
+        var reasons = formatReasons(fmt);
+        if (reasons) {
+            // autosave must never interrupt with a dialog: it skips the write
+            // and lets the session snapshot be the safety net instead
+            if (!silent) formatBlockedDialog(fmt, reasons);
+            return false;
+        }
+        if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
+        setStatus("Saving...", "info", 0);
+        fmt.save(fp, fn, function () {
+            setStatus("Saved " + fn);
+            // a one-way rendering is a copy, not the document's own file - the
+            // editor keeps its path and stays dirty
+            if (!fmt.oneWay) {
+                filepath = fp; filename = fn;
+                loadedFromImport = false;
+                markClean();
+                addRecent(fp, fn);
+                saveSession();
+            }
+            if (cb) cb();
+        }, function (err) {
+            setStatus("Save failed: " + err, "error");
+        });
+        return true;
+    }
+
     function save(cb) {
     function save(cb) {
         if (!filepath) { saveAs(cb); return; }
         if (!filepath) { saveAs(cb); return; }
         doSaveTo(filepath, filename, cb);
         doSaveTo(filepath, filename, cb);
     }
     }
-    function saveAs(cb) {
-        var defName = filename || (cfg.defaultFileName + cfg.extension);
-        if (extOf(defName) !== cfg.extension) defName = stripExt(defName) + cfg.extension;
+    function saveAs(cb) { saveAsFormat(null, cb); }
+    /*
+        `fmt` is a cfg.saveFormats entry, or null for the native format. The
+        format fixes the extension, so the picker only supplies the name.
+    */
+    function saveAsFormat(fmt, cb) {
+        var ext = fmt ? fmt.ext : cfg.extension;
+        // ask before making the user pick a filename, not after
+        var reasons = formatReasons(fmt);
+        if (reasons) { formatBlockedDialog(fmt, reasons); return; }
+        var defName = stripExt(filename || cfg.defaultFileName) + ext;
         ao_module_openFileSelector(function (files) {
         ao_module_openFileSelector(function (files) {
             if (files && files.length > 0) {
             if (files && files.length > 0) {
                 var fp = files[0].filepath;
                 var fp = files[0].filepath;
                 var fn = files[0].filename;
                 var fn = files[0].filename;
-                if (extOf(fn) !== cfg.extension) {
-                    fp += cfg.extension;
-                    fn += cfg.extension;
+                if (extOf(fn) !== ext) {
+                    fp += ext;
+                    fn += ext;
                 }
                 }
                 doSaveTo(fp, fn, cb);
                 doSaveTo(fp, fn, cb);
             }
             }
@@ -636,12 +755,20 @@ var OfficeApp = (function () {
             force_path_overwrite: !filepath
             force_path_overwrite: !filepath
         });
         });
     }
     }
-    function doSaveTo(fp, fn, cb) {
+    // returns false when the write was declined (format cannot hold the doc)
+    function doSaveTo(fp, fn, cb, silent) {
+        var ext = extOf(fn);
+        if (ext !== cfg.extension) {
+            // one of the app's own foreign formats - including a document
+            // opened from one and saved straight back with Ctrl+S
+            var fmt = findSaveFormat(ext, false);
+            if (fmt) return doSaveForeign(fp, fn, fmt, cb, silent);
+        }
         if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
         if (cfg.onBeforeSave) { try { cfg.onBeforeSave(); } catch (e) { } }
         setStatus("Saving...", "info", 0);
         setStatus("Saving...", "info", 0);
         var env;
         var env;
         try { env = buildEnvelope(); }
         try { env = buildEnvelope(); }
-        catch (e) { setStatus("Save failed: " + e.message, "error"); return; }
+        catch (e) { setStatus("Save failed: " + e.message, "error"); return false; }
         var payload = JSON.stringify(env);
         var payload = JSON.stringify(env);
         var done = function () {
         var done = function () {
             filepath = fp; filename = fn;
             filepath = fp; filename = fn;
@@ -667,13 +794,18 @@ var OfficeApp = (function () {
         } else {
         } else {
             vfsSave(fp, payload, done, fail);
             vfsSave(fp, payload, done, fail);
         }
         }
+        return true;
     }
     }
 
 
     /* ---------- autosave ---------- */
     /* ---------- autosave ---------- */
     function autosaveEnabled() { return getSetting("autosave", true); }
     function autosaveEnabled() { return getSetting("autosave", true); }
     function autosaveTick() {
     function autosaveTick() {
         if (dirty && filepath && autosaveEnabled()) {
         if (dirty && filepath && autosaveEnabled()) {
-            save();
+            // a document living in a foreign format is autosaved only while
+            // that format can still hold it; when it cannot, doSaveTo's silent
+            // mode declines the write rather than interrupting with a dialog,
+            // and the session snapshot below protects the work instead
+            if (!doSaveTo(filepath, filename, null, true)) saveSession();
         } else if (dirty) {
         } else if (dirty) {
             // unsaved (or autosave-off) documents still get a session
             // unsaved (or autosave-off) documents still get a session
             // snapshot so "Restore from previous session" can recover them
             // snapshot so "Restore from previous session" can recover them
@@ -816,6 +948,35 @@ var OfficeApp = (function () {
         if (x + w > window.innerWidth) x = Math.max(4, r.left - w - 2);
         if (x + w > window.innerWidth) x = Math.max(4, r.left - w - 2);
         positionFloatMenu($sm, x, r.top - 4);
         positionFloatMenu($sm, x, r.top - 4);
     }
     }
+    /*
+        With foreign writers declared, "Save as" becomes a format picker whose
+        first entry is the native container; without them it stays the plain
+        Save As command it has always been.
+    */
+    function saveAsMenuItem() {
+        var list = saveFormats();
+        if (!list.length) {
+            return {
+                label: "Save as...", icon: "copy outline", key: "Ctrl+Shift+S",
+                action: function () { saveAs(); }
+            };
+        }
+        var sub = [
+            {
+                label: cfg.fileTypeName + " (" + cfg.extension + ")",
+                icon: "save", key: "Ctrl+Shift+S",
+                action: function () { saveAsFormat(null); }
+            },
+            { sep: true }
+        ];
+        list.forEach(function (f) {
+            sub.push({
+                label: formatLabel(f), icon: f.icon || "file outline",
+                action: function () { saveAsFormat(f); }
+            });
+        });
+        return { label: "Save as", icon: "copy outline", sub: sub };
+    }
     function renderMenuItems($drop, items, depth) {
     function renderMenuItems($drop, items, depth) {
         depth = depth || 0;
         depth = depth || 0;
         $drop.empty();
         $drop.empty();
@@ -930,7 +1091,7 @@ var OfficeApp = (function () {
                 },
                 },
                 { sep: true },
                 { sep: true },
                 { label: "Save", icon: "save", key: "Ctrl+S", action: function () { save(); } },
                 { label: "Save", icon: "save", key: "Ctrl+S", action: function () { save(); } },
-                { label: "Save as...", icon: "copy outline", key: "Ctrl+Shift+S", action: function () { saveAs(); } },
+                saveAsMenuItem(),
                 { label: "Auto-save", checked: autosaveEnabled, action: function () { setSetting("autosave", !autosaveEnabled()); } }
                 { label: "Auto-save", checked: autosaveEnabled, action: function () { setSetting("autosave", !autosaveEnabled()); } }
             ];
             ];
             if (cfg.fileMenuExtras && cfg.fileMenuExtras.length) {
             if (cfg.fileMenuExtras && cfg.fileMenuExtras.length) {

+ 3 - 0
src/web/Office/sheets/sheets.css

@@ -206,6 +206,9 @@ body.dark {
     z-index: 16;
     z-index: 16;
 }
 }
 #shGrid.sh-filling, #shGrid.sh-filling .sh-cell { cursor: crosshair; }
 #shGrid.sh-filling, #shGrid.sh-filling .sh-cell { cursor: crosshair; }
+/* grabbing the selection border moves the block - the rule has to name
+   .sh-cell too, since each cell's own `cursor: cell` beats the container's */
+#shGrid.sh-movesel, #shGrid.sh-movesel .sh-cell { cursor: move; }
 
 
 /* floating cell editor */
 /* floating cell editor */
 #shCellInput {
 #shCellInput {

+ 6 - 1
src/web/Office/sheets/sheets.js

@@ -1140,6 +1140,7 @@ var SheetsApp = (function () {
         if (onSelBorder(gp)) {
         if (onSelBorder(gp)) {
             commitEdit(false);
             commitEdit(false);
             drag = { mode: "movesel", srcRg: selRange(), grab: cellAtPos(gp), mv: { dC: 0, dR: 0 } };
             drag = { mode: "movesel", srcRg: selRange(), grab: cellAtPos(gp), mv: { dC: 0, dR: 0 } };
+            gridEl.classList.add("sh-movesel");
             try { gridEl.setPointerCapture(e.pointerId); } catch (err) { }
             try { gridEl.setPointerCapture(e.pointerId); } catch (err) { }
             e.preventDefault();
             e.preventDefault();
             return;
             return;
@@ -1162,7 +1163,7 @@ var SheetsApp = (function () {
         if (!drag) {
         if (!drag) {
             // hover feedback: the selection border is grabbable
             // hover feedback: the selection border is grabbable
             var onEdge = e.target !== fillEl && onSelBorder(gridPos(e));
             var onEdge = e.target !== fillEl && onSelBorder(gridPos(e));
-            gridEl.style.cursor = onEdge ? "move" : "";
+            gridEl.classList.toggle("sh-movesel", onEdge);
             return;
             return;
         }
         }
         lastPointerEvt = e;
         lastPointerEvt = e;
@@ -1220,6 +1221,7 @@ var SheetsApp = (function () {
         var d = drag;
         var d = drag;
         drag = null;
         drag = null;
         gridEl.classList.remove("sh-filling");
         gridEl.classList.remove("sh-filling");
+        gridEl.classList.remove("sh-movesel");
         try { gridEl.releasePointerCapture(e.pointerId); } catch (err) { }
         try { gridEl.releasePointerCapture(e.pointerId); } catch (err) { }
         if (d.mode === "fill" && d.fillTo && (d.fillTo.dC || d.fillTo.dR)) {
         if (d.mode === "fill" && d.fillTo && (d.fillTo.dC || d.fillTo.dR)) {
             applyFill(d.startRg, d.fillTo.dC, d.fillTo.dR);
             applyFill(d.startRg, d.fillTo.dC, d.fillTo.dR);
@@ -2274,6 +2276,9 @@ var SheetsApp = (function () {
                 ".xlsx": function (fp, fn) { SheetsIO.importXlsx(fp, fn); },
                 ".xlsx": function (fp, fn) { SheetsIO.importXlsx(fp, fn); },
                 ".ods": function (fp, fn) { SheetsIO.importOds(fp, fn); }
                 ".ods": function (fp, fn) { SheetsIO.importOds(fp, fn); }
             },
             },
+            // a workbook opened from one of these keeps saving back into it
+            // (File > Save as offers the same list)
+            saveFormats: SheetsIO.saveFormats,
 
 
             onUndo: doUndo,
             onUndo: doUndo,
             onRedo: doRedo,
             onRedo: doRedo,

+ 113 - 4
src/web/Office/sheets/sheets_io.js

@@ -355,7 +355,9 @@ var SheetsIO = (function () {
         if (t.indexOf(delim) >= 0 || t.indexOf("\n") >= 0 || t.indexOf("\r") >= 0) return '"' + t + '"';
         if (t.indexOf(delim) >= 0 || t.indexOf("\n") >= 0 || t.indexOf("\r") >= 0) return '"' + t + '"';
         return t;
         return t;
     }
     }
-    function exportDelimited(delim) {
+    // the active sheet's used range as delimited text (values, not formulas),
+    // with the BOM Excel needs to read it back as UTF-8
+    function delimitedText(delim) {
         var ur = Core.usedRange();
         var ur = Core.usedRange();
         var lines = [];
         var lines = [];
         for (var r = ur.r1; r <= ur.r2; r++) {
         for (var r = ur.r1; r <= ur.r2; r++) {
@@ -371,9 +373,12 @@ var SheetsIO = (function () {
             }
             }
             lines.push(row.join(delim));
             lines.push(row.join(delim));
         }
         }
+        return "" + lines.join("\r\n");
+    }
+    function exportDelimited(delim) {
         var ext = delim === "\t" ? ".tsv" : ".csv";
         var ext = delim === "\t" ? ".tsv" : ".csv";
         var name = OfficeApp.stripExt(OfficeApp.getFileName() || "spreadsheet") + ext;
         var name = OfficeApp.stripExt(OfficeApp.getFileName() || "spreadsheet") + ext;
-        var blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
+        var blob = new Blob([delimitedText(delim)], { type: "text/csv;charset=utf-8" });
         var a = document.createElement("a");
         var a = document.createElement("a");
         a.href = URL.createObjectURL(blob);
         a.href = URL.createObjectURL(blob);
         a.download = name;
         a.download = name;
@@ -382,6 +387,108 @@ var SheetsIO = (function () {
         setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 800);
         setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 800);
         OfficeApp.setStatus("Exported " + name);
         OfficeApp.setStatus("Exported " + name);
     }
     }
+    // Save As / save-back target: writes into the ArozOS file system rather
+    // than downloading, so the document can go on living in that file
+    function saveDelimited(delim, fp, fn, done, fail) {
+        OfficeApp.vfsSave(fp, delimitedText(delim), done, fail);
+    }
+
+    /* ========== what each foreign format cannot hold ==========
+       Returned to OfficeApp as plain-string reasons: a non-empty list makes
+       it refuse the save and steer the user to .xlsa instead of quietly
+       shipping a file that has lost content. Purely visual formatting (fonts,
+       colors, number formats, column widths) is NOT counted - it never
+       survived a text grid and blocking on it would nag on every edit. */
+    function countCells(s, pred) {
+        var n = 0;
+        Object.keys(s.cells || {}).forEach(function (k) {
+            if (pred(s.cells[k])) n++;
+        });
+        return n;
+    }
+    function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }
+    // .csv / .tsv: one sheet of plain values, nothing else
+    function delimitedUnsupported() {
+        var body = Core.getBody();
+        var s = Core.sheet();
+        var out = [];
+        if (body.sheets.length > 1) {
+            out.push(plural(body.sheets.length, "sheet", "sheets") +
+                " - a delimited text file holds only one");
+        }
+        var formulas = countCells(s, function (cell) {
+            return cell && typeof cell.v === "string" && cell.v.charAt(0) === "=";
+        });
+        if (formulas) {
+            out.push(plural(formulas, "formula", "formulas") + " - only " +
+                (formulas === 1 ? "its current value" : "their current values") + " would be kept");
+        }
+        var notes = countCells(s, function (cell) { return cell && cell.n; });
+        if (notes) out.push(plural(notes, "cell note", "cell notes"));
+        if (s.charts && s.charts.length) out.push(plural(s.charts.length, "chart", "charts"));
+        if (s.merges && s.merges.length) {
+            out.push(plural(s.merges.length, "merged cell range", "merged cell ranges"));
+        }
+        return out;
+    }
+    // .ods: everything but charts round-trips (mod/office/ods_writer.go)
+    function odsUnsupported() {
+        var n = 0;
+        Core.getBody().sheets.forEach(function (s) { n += (s.charts || []).length; });
+        return n ? [plural(n, "chart", "charts") +
+            " - the OpenDocument spreadsheet writer cannot store charts"] : [];
+    }
+
+    /* server-side writers, shared with the Export menu but reporting through
+       the framework's save callbacks instead of a toast */
+    function saveViaBackend(action, fp, done, fail) {
+        OfficeApp.agirunLarge(XLSX_BACKEND, {
+            action: action,
+            dest: fp,
+            data: JSON.stringify(Core.getBody())
+        }, "data", function () { done(); }, fail, 180000);
+    }
+    function savePdf(fp, fn, done, fail) {
+        var model;
+        try { model = Core.buildPrintModel(); }
+        catch (e) { fail(e.message); return; }
+        OfficeApp.agirunLarge(XLSX_BACKEND, {
+            action: "export-pdf",
+            dest: fp,
+            data: JSON.stringify(model)
+        }, "data", function () { done(); }, fail, 180000);
+    }
+    /*
+        The formats File > Save as offers besides .xlsa, and the ones a
+        document opened from .xlsx/.ods/.csv/.tsv is saved back into.
+        PDF is oneWay: it is a rendering, so saving one leaves the document
+        itself still pointing at its own file.
+    */
+    var SAVE_FORMATS = [
+        {
+            ext: ".xlsx", label: "Excel workbook (.xlsx)", icon: "file excel outline",
+            save: function (fp, fn, done, fail) { saveViaBackend("export", fp, done, fail); }
+        },
+        {
+            ext: ".ods", label: "OpenDocument spreadsheet (.ods)", icon: "file alternate outline",
+            unsupported: odsUnsupported,
+            save: function (fp, fn, done, fail) { saveViaBackend("export-odf", fp, done, fail); }
+        },
+        {
+            ext: ".pdf", label: "PDF document (.pdf)", icon: "file pdf outline",
+            oneWay: true, save: savePdf
+        },
+        {
+            ext: ".csv", label: "CSV (.csv)", icon: "file alternate outline",
+            unsupported: delimitedUnsupported,
+            save: function (fp, fn, done, fail) { saveDelimited(",", fp, fn, done, fail); }
+        },
+        {
+            ext: ".tsv", label: "TSV (.tsv)", icon: "file alternate outline",
+            unsupported: delimitedUnsupported,
+            save: function (fp, fn, done, fail) { saveDelimited("\t", fp, fn, done, fail); }
+        }
+    ];
 
 
     /* ================= XLSX (server-side via the office AGI lib) ================= */
     /* ================= XLSX (server-side via the office AGI lib) ================= */
     // shared by .xlsx ("import") and .ods ("import-odf")
     // shared by .xlsx ("import") and .ods ("import-odf")
@@ -403,8 +510,9 @@ var SheetsIO = (function () {
                 return;
                 return;
             }
             }
             Core.setBody(b);
             Core.setBody(b);
-            OfficeApp.markDirty();
-            OfficeApp.setStatus("Imported " + fn + " - use Save to store it as .xlsa");
+            // the framework kept us attached to the source file, so Save
+            // writes straight back to it in its own format
+            OfficeApp.setStatus("Opened " + fn);
         }, function () {
         }, function () {
             OfficeApp.hideBusy();
             OfficeApp.hideBusy();
             OfficeApp.toast("Import failed: cannot reach the ArozOS backend", "error");
             OfficeApp.toast("Import failed: cannot reach the ArozOS backend", "error");
@@ -727,6 +835,7 @@ var SheetsIO = (function () {
         parseDelimited: parseDelimited,
         parseDelimited: parseDelimited,
         importDelimited: importDelimited,
         importDelimited: importDelimited,
         exportDelimited: exportDelimited,
         exportDelimited: exportDelimited,
+        saveFormats: SAVE_FORMATS,
         importXlsx: importXlsx,
         importXlsx: importXlsx,
         importOds: importOds,
         importOds: importOds,
         importXlsxDialog: importXlsxDialog,
         importXlsxDialog: importXlsxDialog,