3 コミット a0bdedbc44 ... ac72e28fe8

作者 SHA1 メッセージ 日付
  Toby Chui ac72e28fe8 Add rich text RTF mode to Text editor 2 週間 前
  Toby Chui 4e9e9c6aa8 Share chunked upload engine across clients 3 週間 前
  Toby Chui d45d453df2 Refine file explorer upload panel 3 週間 前

+ 20 - 8
src/file_system.go

@@ -735,6 +735,23 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 			}
 			expectingBinary = false
 
+			/*
+				Chunks are accepted strictly in index order, and "next" is sent
+				once per accepted chunk - clients that keep several chunks in
+				flight count those acknowledgements to know which chunks landed.
+				Anything else is dropped without a reply:
+				- index < blockCounter: a copy of a chunk already written, resent
+				  by the client after an acknowledgement timeout. Accepting it
+				  again would count its bytes twice in the size and full-file
+				  checksum.
+				- index > blockCounter: sent ahead in a pipelined window before
+				  the chunk at blockCounter failed its CRC check. The client
+				  rewinds to the retry and sends it again.
+			*/
+			if pendingChunkIndex != blockCounter {
+				continue
+			}
+
 			// Verify chunk CRC32
 			chunkSum := crc32.ChecksumIEEE(message)
 			chunkSumBytes := []byte{byte(chunkSum >> 24), byte(chunkSum >> 16), byte(chunkSum >> 8), byte(chunkSum)}
@@ -748,15 +765,10 @@ func system_fs_handleLowMemoryUpload(w http.ResponseWriter, r *http.Request) {
 				continue
 			}
 
-			// Chunk verified – write to tmp folder.
-			// Use pendingChunkIndex as the canonical filename so that a retry overwrites
-			// the previous (corrupted) attempt rather than creating a duplicate entry.
+			// Chunk verified – write to tmp folder
 			chunkFilepath := filepath.Join(uploadFolder, "upld_"+strconv.Itoa(pendingChunkIndex))
-			if pendingChunkIndex == blockCounter {
-				// First time this chunk index is successfully received
-				chunkName = append(chunkName, chunkFilepath)
-				blockCounter++
-			}
+			chunkName = append(chunkName, chunkFilepath)
+			blockCounter++
 
 			var writeErr error
 			if isHugeFile {

+ 65 - 3
src/web/SystemAO/file_system/file_explorer.css

@@ -1556,10 +1556,15 @@ body.fmResizing *{
     The floating upload / zip task card at the bottom right. Markup shell lives
     in file_explorer.html, rows are drawn by js/explorer/uploadui.js.
 */
+/*
+    Floats above the status bar rather than over it, so the tray buttons (the
+    upload list toggle included) stay clickable. --fm-statusbar-h is measured
+    by applyUploadPanelVisibility(); the fallback is the bar's usual height.
+*/
 #uploadTab{
     position: fixed;
-    right: 16px;
-    bottom: 16px;
+    right: 8px;
+    bottom: calc(var(--fm-statusbar-h, 39px) + 10px);
     width: 360px;
     max-width: calc(100% - 32px);
     background-color: var(--upload_bg);
@@ -1714,6 +1719,14 @@ body.fmResizing *{
     text-align: center;
 }
 
+/* The summary's overall percentage: its own right aligned cell, like the numbers */
+#uploadTab .uploadPct{
+    display: inline-block;
+    min-width: 4ch;
+    margin-left: 8px;
+    text-align: right;
+}
+
 /*
     Always the right hand end of the status line, whatever is to its left: a
     byte counter, the download link of a finished download, or nothing at all.
@@ -1971,15 +1984,64 @@ body.fmResizing *{
     background-color: var(--fs-accent-soft);
 }
 
+/*
+    Minimized card (#uploadTab.compact, see toggleUploadMinimize)
+
+    The same panel shrunk in place to its summary row: ring, running total and
+    the maximize / close buttons, which only exist on this view because the
+    header that normally carries the controls is hidden.
+*/
+#uploadTab .uploadCompactBtn{
+    display: none;
+}
+
+/*
+    Shrink to fit rather than a fixed width: the total's label differs in
+    length between locales, and it must never be cut off mid number.
+*/
+#uploadTab.compact{
+    width: auto;
+    min-width: 240px;
+}
+#uploadTab.compact .uploadHeader,
+#uploadTab.compact .uploadList,
+#uploadTab.compact .uploadActions{
+    display: none;
+}
+#uploadTab.compact .uploadSummary{
+    gap: 8px;
+    padding: 8px 8px 8px 14px;
+    margin-top: 0px;
+    border-top: none;
+    background-color: var(--uploadtab_bg);
+}
+#uploadTab.compact .uploadSummaryIcon svg{
+    width: 22px;
+    height: 22px;
+}
+/* Not allowed to shrink: the card grows with the text instead of clipping it */
+#uploadTab.compact .uploadSummaryText{
+    flex: 0 0 auto;
+    overflow: visible;
+    font-size: 13px;
+    color: var(--uploadtab_txt);
+}
+#uploadTab.compact .uploadCompactBtn{
+    display: flex;
+}
+
 
 @media screen and (max-width: 500px) {
     #uploadTab{
         right: 12px;
         left: 12px;
-        bottom: 12px;
+        bottom: calc(var(--fm-statusbar-h, 37px) + 8px);
         width: auto;
         max-width: none;
     }
+    #uploadTab.compact{
+        min-width: 0px;
+    }
     .uploadList{
         max-height: 40vh;
     }

+ 8 - 0
src/web/SystemAO/file_system/file_explorer.html

@@ -11,6 +11,7 @@
         <script type="text/javascript" src="../../script/semantic/semantic.min.js"></script>
         <script type="text/javascript" src="../../script/ao_module.js"></script>
         <script type="text/javascript" src="../../script/applocale.js"></script>
+        <script type="text/javascript" src="../../script/chunkupload.js"></script>
         <link rel="stylesheet" href="../../script/ao.css">
         <!-- Design tokens and components shared with the File Selector. Loaded
              before file_explorer.css so app rules can override them. -->
@@ -554,6 +555,13 @@
             <div class="uploadSummary">
                 <span class="uploadSummaryIcon" id="uploadSummaryIcon" data-fsicon="taskProgress"></span>
                 <span class="uploadSummaryText" id="uploadSummaryText"></span>
+                <!-- Only shown on the minimized card, where the header is hidden -->
+                <div class="uploadHeaderBtn uploadCompactBtn" onclick="restoreUploadPanel();" title="Maximize">
+                    <span data-fsicon="maximize"></span>
+                </div>
+                <div class="uploadHeaderBtn uploadCompactBtn" onclick="closeUploadTab();" title="Close">
+                    <span data-fsicon="close"></span>
+                </div>
             </div>
             <div class="uploadActions">
                 <div class="uploadActionBtn" onclick="clearCompletedUploads();">

+ 6 - 3
src/web/SystemAO/file_system/js/explorer/state.js

@@ -95,9 +95,10 @@ let uploadPendingList = []; //Upload pending queue for mass upoad
 let uploadRetryMap = new Map(); //taskUUID -> {file, targetDir} for WebSocket upload retry
 let lowMemoryMode = true;   //Upload with low memory mode channel
 let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
-let uploadFileChunkSize = 1024 * 512; //512KB, 4MB not working quite well on slow network
+let uploadFileChunkSize = 1024 * 1024 * 1; //1MB, 4MB not working quite well on slow network
+let uploadWindowSize = 8 * 1024 * 1024; //Bytes of chunks kept in flight per upload connection (see script/chunkupload.js)
 let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
-const CHUNK_TIMEOUT_MS = 30000; //30s timeout waiting for server "next" ack before retrying a chunk
+const CHUNK_TIMEOUT_MS = 30000; //30s without any server "next" ack (once everything queued has been sent) before resending the oldest chunk
 
 /*
     Heartbeat interval while an upload is paused. Comfortably under the 60s idle
@@ -110,7 +111,7 @@ const CHUNK_TIMEOUT_MS = 30000; //30s timeout waiting for server "next" ack befo
 */
 const UPLOAD_PAUSE_PING_MS = 20000;
 const UPLOAD_PAUSE_TIMEOUT_CLOSE_CODE = 4001;
-const MAX_CHUNK_RETRIES = 3;    //Maximum retries per individual chunk before failing the upload
+const MAX_CHUNK_RETRIES = 3;    //Maximum retries without any progress before failing the upload
 
 /*
     Transfer panel state (see js/explorer/uploadui.js)
@@ -127,6 +128,8 @@ let uploadTaskInfo = new Map();
 let uploadTransferMap = new Map();
 //Panel collapsed to the #fmUploadListBtn in the status bar; a new task expands it
 let uploadPanelCollapsed = false;
+//Panel minimized to the compact card (ring + total only) in the same corner
+let uploadPanelCompact = false;
 const MAX_FINISHED_UPLOAD_ROWS = 60; //Trim completed rows past this to keep mass uploads responsive
 
 /*

+ 67 - 241
src/web/SystemAO/file_system/js/explorer/upload.js

@@ -48,7 +48,7 @@ function initUploadMode(){
 
 function uploadFile(file, uuid=undefined, targetDir=undefined) {
     if (file.size > postUploadModeCutoff && lowMemoryMode){
-            /*
+        /*
             Low Memory Upload Mode
         */
         var filename = encodeURIComponent(file.name);
@@ -116,42 +116,14 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
             hugeFileMode = "&hugefile=true";
         }
 
-        let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(filename) + "&path=" + encodeURIComponent(uploadDir) + hugeFileMode);
-        let currentSendingIndex = 0;
-        let chunks = Math.ceil(file.size/uploadFileChunkSize);
-
-        // Per-chunk retry state
-        let chunkRetryCount = 0;
-        let chunkTimeoutTimer = null;
-
-        // Running CRC32 state across all chunks for full-file checksum
-        // Initialized to 0xFFFFFFFF (pre-conditioning), finalized with XOR at the end
-        let runningCRC32State = 0xFFFFFFFF;
-        // Track which chunk indices have already been factored into the running state
-        // so that retries do not corrupt the full-file CRC32
-        let chunkCRC32Committed = {};
-
         // Store file reference in retry map so the user can retry on failure
         uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadDir))});
 
-        /*
-            Pause / resume state.
-
-            Chunks are pulled by the server: every "next" acknowledgement asks
-            for one more. Pausing therefore just means not answering that ask
-            and remembering that one is outstanding, so nothing has to be
-            unwound and the transfer resumes exactly where it stopped.
-        */
-        let paused = false;
-        let resumeWaiting = false;
         let aborted = false;
         let completed = false;
-        let pausePingTimer = null;
 
         // Mark an upload task as failed and reveal the retry button
         function markUploadFailed(tUUID) {
-            clearTimeout(chunkTimeoutTimer);
-            stopPausePing();
             if (aborted) {
                 return;
             }
@@ -160,126 +132,88 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
         }
 
         /*
-            Keep-alive while paused
-
-            A paused upload sends nothing at all, and neither an idle-timeout on
-            our own server nor a reverse proxy in front of it will keep such a
-            connection open. So the client keeps a slow heartbeat going for as
-            long as the pause lasts; the server answers each one, which puts
-            traffic on the wire in both directions.
+            The chunking, pipelining, checksums, retries and the pause
+            keep-alive all live in script/chunkupload.js, shared with the
+            desktop. This side only maps its events onto the transfer panel.
         */
-        function startPausePing() {
-            stopPausePing();
-            pausePingTimer = setInterval(function () {
-                if (socket.readyState != WebSocket.OPEN) {
-                    stopPausePing();
-                    return;
+        let transfer = ChunkUpload.start({
+            url: protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(filename) + "&path=" + encodeURIComponent(uploadDir) + hugeFileMode,
+            file: file,
+            chunkSize: uploadFileChunkSize,
+            windowSize: uploadWindowSize,
+            ackTimeout: CHUNK_TIMEOUT_MS,
+            maxRetries: MAX_CHUNK_RETRIES,
+            pingInterval: UPLOAD_PAUSE_PING_MS,
+            onProgress: function(loaded, total){
+                setUploadTaskProgress(taskUUID, loaded, total);
+            },
+            onProcessing: function(){
+                setUploadTaskState(taskUUID, "processing");
+            },
+            onMove: function(status){
+                //File move from tmp to archive – show progress
+                setUploadTaskState(taskUUID, "processing");
+                setUploadTaskStatusText(taskUUID, status);
+            },
+            onDone: function(){
+                //Merge completed successfully
+                uploadRetryMap.delete(taskUUID);
+                unregisterUploadTransfer(taskUUID);
+                completed = true;
+                setUploadTaskState(taskUUID, "done");
+            },
+            onError: function(message, fromServer){
+                if (fromServer){
+                    msgbox("red remove", message);
                 }
-                socket.send(JSON.stringify({ping: true}));
-            }, UPLOAD_PAUSE_PING_MS);
-        }
-
-        function stopPausePing() {
-            if (pausePingTimer != null) {
-                clearInterval(pausePingTimer);
-                pausePingTimer = null;
-            }
-        }
-
-        // Send a specific chunk by index.
-        // Reads the slice as ArrayBuffer, computes CRC32, sends metadata then binary.
-        // Sets a CHUNK_TIMEOUT_MS timer; on expiry retries up to MAX_CHUNK_RETRIES times.
-        async function sendChunk(id) {
-            var offsetStart = id * uploadFileChunkSize;
-            var offsetEnd   = id * uploadFileChunkSize + uploadFileChunkSize;
-            var thisblob = file.slice(offsetStart, offsetEnd);
-
-            let arrayBuffer;
-            try {
-                arrayBuffer = await thisblob.arrayBuffer();
-            } catch(e) {
-                console.error("[Upload] Failed to read chunk " + id + ": " + e);
                 markUploadFailed(taskUUID);
-                return;
-            }
-
-            let bytes = new Uint8Array(arrayBuffer);
-
-            // Update the running full-file CRC32 only on the first attempt for each
-            // chunk index so that retries do not double-count the bytes
-            if (!chunkCRC32Committed[id]) {
-                runningCRC32State = crc32UpdateState(runningCRC32State, bytes);
-                chunkCRC32Committed[id] = true;
-            }
+            },
+            onClose: function(event){
+                unregisterUploadTransfer(taskUUID);
 
-            // Compute a standalone CRC32 for this chunk for transmission verification
-            let chunkCRCHex = crc32Hex(bytes);
-
-            // Protocol: text metadata frame, then binary data frame
-            socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
-            socket.send(arrayBuffer);
-
-            // Report the bytes actually handed to the socket
-            setUploadTaskProgress(taskUUID, Math.min(file.size, offsetEnd), file.size);
-
-            // (Re)start the per-chunk acknowledgement timeout
-            clearTimeout(chunkTimeoutTimer);
-            chunkTimeoutTimer = setTimeout(function() {
-                if (chunkRetryCount < MAX_CHUNK_RETRIES) {
-                    chunkRetryCount++;
-                    console.warn("[Upload] Chunk " + id + " ACK timeout – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
-                    sendChunk(id);
-                } else {
-                    console.error("[Upload] Chunk " + id + " failed after " + MAX_CHUNK_RETRIES + " retries");
-                    markUploadFailed(taskUUID);
-                    socket.close();
+                /*
+                    Any close that is not the end of a finished upload and not the
+                    user cancelling leaves the task stranded mid-transfer, so offer
+                    retry rather than a row frozen at whatever percentage it reached.
+                    The server closes with uploadPauseCloseCode when a pause has been
+                    left running for too long, which is the case worth naming.
+                */
+                if (!completed && !aborted){
+                    if (event.code == UPLOAD_PAUSE_TIMEOUT_CLOSE_CODE){
+                        msgbox("caution", applocale.getString("upload/pauseExpired",
+                            "Upload cancelled: paused for too long"));
+                    }
+                    setUploadTaskState(taskUUID, "failed");
                 }
-            }, CHUNK_TIMEOUT_MS);
-        }
 
-        // Send whatever the server asked for next, or park the request if the
-        // user has paused. Every send path goes through here so pause only has
-        // to be handled once.
-        async function sendNext() {
-            if (paused || aborted) {
-                resumeWaiting = true;
-                return;
-            }
-            if (currentSendingIndex >= chunks){
-                // All chunks sent and acknowledged - send done + full-file checksum
-                let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
-                socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
-                setUploadTaskState(taskUUID, "processing");
-            }else{
-                await sendChunk(currentSendingIndex);
-                currentSendingIndex++;
+                uploadingFileCount--;
+                updateUploadFileCount();
+                //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
+                setTimeout(function(){
+                    if (uploadPendingList.length > 0){
+                        let nextFile = uploadPendingList.shift();
+                        uploadFile(nextFile.File, nextFile.UUID, nextFile.TargetDir);
+                    }
+                }, 100)
+            },
+            onSocketError: function(error){
+                console.error("[Upload] WebSocket error:", error);
+                // Mark the task as failed and show the retry button
+                markUploadFailed(taskUUID);
             }
-        }
+        });
 
         registerUploadTransfer(taskUUID, {
             pausable: true,
             pause: function(){
-                paused = true;
-                //The chunk clock must stop too, or the outstanding chunk would
-                //"time out" while the upload is legitimately sitting idle
-                clearTimeout(chunkTimeoutTimer);
-                try { socket.send(JSON.stringify({pause: true})); } catch(e) {}
-                startPausePing();
+                transfer.pause();
             },
             resume: function(){
-                paused = false;
-                stopPausePing();
-                try { socket.send(JSON.stringify({resume: true})); } catch(e) {}
-                if (resumeWaiting){
-                    resumeWaiting = false;
-                    sendNext();
-                }
+                transfer.resume();
             },
             abort: function(){
                 aborted = true;
-                clearTimeout(chunkTimeoutTimer);
-                stopPausePing();
-                try { socket.close(); } catch(e) {}
+                transfer.abort();
             }
         });
 
@@ -287,114 +221,6 @@ function uploadFile(file, uuid=undefined, targetDir=undefined) {
         updateUploadFileCount();
         uploadingFileCount++;
 
-        //Start sending
-        socket.onopen = async function(e) {
-            /*
-                The pause button is live from the moment the row appears, which
-                can be before this socket finished connecting. A pause raised in
-                that window could not be sent, so replay it here - otherwise the
-                server never learns the upload is paused and reaps it as idle.
-            */
-            if (paused){
-                try { socket.send(JSON.stringify({pause: true})); } catch(e) {}
-                startPausePing();
-                return;
-            }
-            currentSendingIndex = 0;
-            await sendNext();
-        };
-
-        socket.onmessage = async function(event) {
-            var incomingValue = event.data;
-
-            if (incomingValue == `{"pong":true}`){
-                //Heartbeat reply while paused - nothing to do, the point is
-                //that a frame crossed the connection in each direction
-                return;
-            }
-
-            if (incomingValue == "next"){
-                // Server acknowledged the last chunk; clear timeout and reset retry counter
-                clearTimeout(chunkTimeoutTimer);
-                chunkRetryCount = 0;
-                await sendNext();
-
-            }else if (incomingValue == "OK"){
-                //Merge completed successfully
-                uploadRetryMap.delete(taskUUID);
-                unregisterUploadTransfer(taskUUID);
-                completed = true;
-                setUploadTaskState(taskUUID, "done");
-            }else{
-                //Try to parse it as JSON
-                try{
-                    var resp = JSON.parse(incomingValue);
-                    if (resp.error !== undefined){
-                        //Server reported an error
-                        msgbox("red remove", resp.error);
-                        markUploadFailed(taskUUID);
-                    }else if (resp.retryChunk !== undefined){
-                        // Server detected a CRC32 mismatch and requests a chunk re-send
-                        clearTimeout(chunkTimeoutTimer);
-                        if (chunkRetryCount < MAX_CHUNK_RETRIES){
-                            chunkRetryCount++;
-                            console.warn("[Upload] Server requested retry for chunk " + resp.retryChunk + " (CRC32 mismatch) – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
-                            await sendChunk(resp.retryChunk);
-                        } else {
-                            console.error("[Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
-                            markUploadFailed(taskUUID);
-                            socket.close();
-                        }
-                    }else if (resp.move !== undefined){
-                        //File move from tmp to archive – show progress
-                        setUploadTaskState(taskUUID, "processing");
-                        setUploadTaskStatusText(taskUUID, resp.move);
-                    }
-                }catch(ex){
-                    //Something else
-                    console.log(ex);
-                }
-            }
-
-        };
-
-        socket.onclose = function(event) {
-            clearTimeout(chunkTimeoutTimer);
-            stopPausePing();
-            unregisterUploadTransfer(taskUUID);
-
-            /*
-                Any close that is not the end of a finished upload and not the
-                user cancelling leaves the task stranded mid-transfer, so offer
-                retry rather than a row frozen at whatever percentage it reached.
-                The server closes with uploadPauseCloseCode when a pause has been
-                left running for too long, which is the case worth naming.
-            */
-            if (!completed && !aborted){
-                if (event.code == UPLOAD_PAUSE_TIMEOUT_CLOSE_CODE){
-                    msgbox("caution", applocale.getString("upload/pauseExpired",
-                        "Upload cancelled: paused for too long"));
-                }
-                setUploadTaskState(taskUUID, "failed");
-            }
-
-            uploadingFileCount--;
-            updateUploadFileCount();
-            //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
-            setTimeout(function(){
-                if (uploadPendingList.length > 0){
-                    let nextFile = uploadPendingList.shift();
-                    uploadFile(nextFile.File, nextFile.UUID, nextFile.TargetDir);
-                }
-            }, 100)
-        };
-
-        socket.onerror = function(error) {
-            console.error("[Upload] WebSocket error:", error);
-            // Mark the task as failed and show the retry button
-            markUploadFailed(taskUUID);
-        };
-
     }else{
         /*
             Standard Upload Mode

+ 64 - 9
src/web/SystemAO/file_system/js/explorer/uploadui.js

@@ -467,20 +467,33 @@ function updateUploadSummary(){
         taskCount++;
     });
 
-    //Every task counts the same towards the ring. Weighting them by size would
-    //need a size for each, which is exactly what a zip task cannot give.
-    let overall = taskCount > 0 ? percentSum / taskCount : 0;
+    /*
+        With every size known the overall figure is bytes moved over bytes in
+        total, so the ring and the percentage agree with the byte counter next
+        to them. A zip task has no size to weigh, so once one is in the list
+        every task counts the same instead.
+    */
+    let bytesKnown = total > 0 && !hasUnknown;
+    let overall = 0;
+    if (bytesKnown){
+        overall = Math.min(100, loaded / total * 100);
+    }else if (taskCount > 0){
+        overall = percentSum / taskCount;
+    }
     setUploadSummaryRing(overall);
 
+    //Floored, so 100% only ever shows once everything is actually through
+    let pctHTML = '<span class="uploadPct">' + Math.floor(overall) + '%</span>';
+
     //The %s slots get the same boxed number markup as the rows. The literal
     //text around them comes from the locale file and never changes width.
     let html = "";
-    if (total > 0 && !hasUnknown){
+    if (bytesKnown){
         html = applocale.getString("upload/total", "Total: %s / %s")
                 .replace("%s", formatUploadBytesHTML(loaded))
-                .replace("%s", formatUploadBytesHTML(total));
+                .replace("%s", formatUploadBytesHTML(total)) + pctHTML;
     }else if (loaded > 0){
-        html = formatUploadBytesHTML(loaded);
+        html = formatUploadBytesHTML(loaded) + pctHTML;
     }else if (taskCount > 0){
         //Nothing to count in bytes - a zip task only ever reports a percentage
         html = Math.round(overall) + "%";
@@ -559,14 +572,22 @@ function applyUploadPanelVisibility(){
     });
 
     if (taskCount == 0){
-        //Nothing left to show: the panel and its button both go away
+        //Nothing left to show: the panel and its button both go away, and the
+        //next batch opens as the full list again
         $("#uploadTab").hide();
         $("#fmUploadListBtn").hide();
         uploadPanelCollapsed = false;
+        uploadPanelCompact = false;
+        $("#uploadTab").removeClass("compact");
         return;
     }
 
     let expanded = !uploadPanelCollapsed;
+    //The panel floats just above the status bar, whose height varies with the
+    //layout (the narrow screen bar is shorter), so it is measured, not assumed
+    let statusBarHeight = $("#fmStatusBar").is(":visible") ? $("#fmStatusBar").outerHeight() : 0;
+    document.getElementById("uploadTab").style.setProperty("--fm-statusbar-h", statusBarHeight + "px");
+    $("#uploadTab").toggleClass("compact", uploadPanelCompact);
     $("#uploadTab").toggle(expanded);
     $("#fmUploadListBtn").css("display", "");
     $("#fmUploadListBtn").toggleClass("active", expanded);
@@ -777,12 +798,28 @@ function collapseUploadPanelForDialog(){
     applyUploadPanelVisibility();
 }
 
-//Collapse the panel down to #fmUploadListBtn. The tasks keep running.
+/*
+    Minimize: shrink the panel in place to a compact card that keeps only the
+    progress ring and the running total, with maximize and close beside them.
+    The tasks keep running, and the choice sticks until the list empties, so
+    reopening from the status bar or a new upload brings back the same view.
+*/
 function toggleUploadMinimize(){
-    uploadPanelCollapsed = true;
+    uploadPanelCompact = true;
+    uploadPanelCollapsed = false;
     applyUploadPanelVisibility();
 }
 
+//Maximize: back from the compact card to the full task list
+function restoreUploadPanel(){
+    uploadPanelCompact = false;
+    uploadPanelCollapsed = false;
+    applyUploadPanelVisibility();
+    //The list was hidden, so the in-flight row may be out of view
+    uploadListUserScrolledAt = 0;
+    scrollActiveUploadIntoView();
+}
+
 //The status bar button: expands the parked panel, or collapses it again
 function toggleUploadPanel(){
     uploadPanelCollapsed = !uploadPanelCollapsed;
@@ -794,7 +831,24 @@ function toggleUploadPanel(){
     }
 }
 
+/*
+    Close. With every task completed there is nothing left to come back to, so
+    the list is cleared and the panel goes away with its status bar button.
+    Otherwise it hides to the status bar, where the transfers keep running. A
+    failed task counts as unfinished: clearing it would throw away its Retry.
+*/
 function closeUploadTab(){
+    let allCompleted = uploadTaskInfo.size > 0;
+    uploadTaskInfo.forEach(function(info){
+        if (info.state != "done"){
+            allCompleted = false;
+        }
+    });
+
+    if (allCompleted){
+        clearCompletedUploads();
+        return;
+    }
     uploadPanelCollapsed = true;
     applyUploadPanelVisibility();
 }
@@ -816,6 +870,7 @@ function removeThisTask(object, taskUUID){
 window.removeThisTask = removeThisTask;
 window.retryUploadFile = retryUploadFile;
 window.toggleUploadMinimize = toggleUploadMinimize;
+window.restoreUploadPanel = restoreUploadPanel;   // the compact card's maximize button
 window.toggleUploadPanel = toggleUploadPanel;   // the status bar button
 window.collapseUploadPanelForDialog = collapseUploadPanelForDialog;
 window.closeUploadTab = closeUploadTab;

+ 1 - 20
src/web/SystemAO/file_system/js/explorer/util.js

@@ -7,26 +7,7 @@
     file_explorer.html - see the <script> block at the end of that file.
 */
 
-    //CRC32 lookup table and helpers for chunk/file integrity verification
-    const crc32Table = (() => {
-        const t = new Uint32Array(256);
-        for (let i = 0; i < 256; i++) {
-            let c = i;
-            for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
-            t[i] = c;
-        }
-        return t;
-    })();
-    //Update a running CRC32 state with new bytes (does not finalize)
-    function crc32UpdateState(state, bytes) {
-        for (let i = 0; i < bytes.length; i++)
-            state = (state >>> 8) ^ crc32Table[(state ^ bytes[i]) & 0xFF];
-        return state;
-    }
-    //Compute standalone CRC32 of a Uint8Array and return hex string
-    function crc32Hex(bytes) {
-        return ((crc32UpdateState(0xFFFFFFFF, bytes) ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
-    }
+    //Upload chunk / file CRC32 lives with the upload protocol in script/chunkupload.js
 
     //Thumbnail base64 sniffing now lives in shared/filethumb.js as
     //FileThumb.thumbExtFromBase64 / FileThumb.base64ToDataURL

+ 1 - 0
src/web/SystemAO/file_system/shared/fsicons.js

@@ -78,6 +78,7 @@
 
         /* Transfer panel */
         minus:       S + '<path d="M5 12h14"/></svg>',
+        maximize:    S + '<rect x="5" y="5" width="14" height="14" rx="2"/></svg>',
         chevronDown: S + '<path d="M6 9.5l6 6 6-6"/></svg>',
         chevronUp:   S + '<path d="M6 14.5l6-6 6 6"/></svg>',
         cloudUpload: S + '<path d="M6.5 19a4.5 4.5 0 0 1-.6-8.96A6 6 0 0 1 17.7 9.2 3.9 3.9 0 0 1 17.5 19z"/><path d="M12 18v-7M9.2 13.3L12 10.5l2.8 2.8"/></svg>',

+ 303 - 40
src/web/Text/index.html

@@ -11,6 +11,7 @@
     <script src="lib/turndown-plugin-gfm.min.js"></script>
     <script src="lib/pdf-lib.min.js"></script>
     <script src="highlighter.js"></script>
+    <script src="rtf.js"></script>
     <style>
         *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
 
@@ -131,6 +132,21 @@
 
         /* hide markdown-only controls when editing plain text */
         body.mode-txt .md-only { display: none !important; }
+        /* rich-text (RTF) mode: its own formatting controls, minus markdown-only ones */
+        body.mode-rtf .no-rtf { display: none !important; }
+        body:not(.mode-rtf) .rtf-only { display: none !important; }
+
+        /* colour swatch dropdowns (RTF text colour / highlight) */
+        .swatch-menu { min-width: 0; width: 176px; }
+        .swatch-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 5px; padding: 4px 2px 2px; }
+        .swatch {
+            width: 22px; height: 22px; border-radius: 5px; cursor: pointer; padding: 0;
+            border: 1px solid rgba(0,0,0,.18);
+        }
+        .swatch:hover { transform: scale(1.12); }
+        .glyph-color { display: inline-flex; flex-direction: column; align-items: center; width: 15px; line-height: 1; }
+        .glyph-color b { font-size: 13px; }
+        .glyph-color i { display: block; width: 14px; height: 3px; border-radius: 1px; margin-top: 1px; }
 
         /* ── Editor surfaces ──────────────────────────────────────────────── */
         #editor-scroll {
@@ -150,6 +166,12 @@
         }
         body.mode-txt #rich  { display: none; }
         body.mode-txt #plain { display: block; }
+        /* RTF keeps spaces and tabs significant */
+        body.mode-rtf #rich p, body.mode-rtf #rich li, body.mode-rtf #rich td, body.mode-rtf #rich th,
+        body.mode-rtf #rich h1, body.mode-rtf #rich h2, body.mode-rtf #rich h3,
+        body.mode-rtf #rich h4, body.mode-rtf #rich h5, body.mode-rtf #rich h6 { white-space: pre-wrap; tab-size: 4; }
+        /* keep highlighted runs readable on the dark theme */
+        body.dark.mode-rtf #rich [style*="background-color"] { color: #1d2230; }
 
         #rich:empty::before, #rich.is-empty::before {
             content: attr(data-ph); color: var(--text2); pointer-events: none;
@@ -514,11 +536,46 @@
         <button class="tb-btn md-only" id="btn-bold"   onclick="actBold()"   title="Bold"><span class="glyph"><strong>B</strong></span></button>
         <button class="tb-btn md-only" id="btn-italic" onclick="actItalic()" title="Italic"><span class="glyph"><em>I</em></span></button>
         <button class="tb-btn md-only" id="btn-strike" onclick="actStrike()" title="Strikethrough"><span class="glyph"><s>S</s></span></button>
-        <button class="tb-btn md-only" id="btn-code"   onclick="actInlineCode()" title="Inline code"><span class="glyph" style="font-family:monospace;">&lt;&gt;</span></button>
+        <button class="tb-btn rtf-only" id="btn-underline" onclick="actUnderline()" title="Underline"><span class="glyph"><u>U</u></span></button>
+        <button class="tb-btn md-only no-rtf" id="btn-code"   onclick="actInlineCode()" title="Inline code"><span class="glyph" style="font-family:monospace;">&lt;&gt;</span></button>
+
+        <div class="menu-wrap rtf-only">
+            <button class="tb-btn" onclick="toggleMenu('fore-menu')" title="Text colour">
+                <span class="glyph-color"><b>A</b><i id="fore-bar" style="background:#c00000;"></i></span>
+            </button>
+            <div class="menu swatch-menu" id="fore-menu"></div>
+        </div>
+        <div class="menu-wrap rtf-only">
+            <button class="tb-btn" onclick="toggleMenu('back-menu')" title="Highlight colour">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l-6 6v3h9l3-3"/><path d="M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg>
+            </button>
+            <div class="menu swatch-menu" id="back-menu"></div>
+        </div>
+        <select id="sel-size" class="tb-select rtf-only" style="width:58px;" onchange="actFontSize()" title="Font size (pt)">
+            <option value="">Size</option>
+            <option>8</option><option>9</option><option>10</option><option>11</option><option>12</option>
+            <option>14</option><option>16</option><option>18</option><option>20</option><option>24</option>
+            <option>28</option><option>36</option><option>48</option><option>72</option>
+        </select>
+
+        <div class="tb-sep rtf-only"></div>
+
+        <button class="tb-btn rtf-only" id="btn-align-left" onclick="actAlign('Left')" title="Align left">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-center" onclick="actAlign('Center')" title="Center">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="5" y1="18" x2="19" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-right" onclick="actAlign('Right')" title="Align right">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/></svg>
+        </button>
+        <button class="tb-btn rtf-only" id="btn-align-full" onclick="actAlign('Full')" title="Justify">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
+        </button>
 
         <div class="tb-sep md-only"></div>
 
-        <button class="tb-btn md-only" onclick="actQuote()" title="Blockquote">
+        <button class="tb-btn md-only no-rtf" onclick="actQuote()" title="Blockquote">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M7 7h4v4H8.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7zm7 0h4v4h-2.5c0 1.4.6 2 2 2v2c-2.5 0-3.5-1.5-3.5-4V7z"/></svg>
         </button>
         <button class="tb-btn md-only" onclick="actBullet()" title="Bullet list">
@@ -527,7 +584,7 @@
         <button class="tb-btn md-only" onclick="actNumber()" title="Numbered list">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="10" y1="6" x2="20" y2="6"/><line x1="10" y1="12" x2="20" y2="12"/><line x1="10" y1="18" x2="20" y2="18"/><text x="2" y="8" font-size="7" fill="currentColor" stroke="none">1</text><text x="2" y="14" font-size="7" fill="currentColor" stroke="none">2</text><text x="2" y="20" font-size="7" fill="currentColor" stroke="none">3</text></svg>
         </button>
-        <button class="tb-btn md-only" onclick="actCodeBlock()" title="Code block">
+        <button class="tb-btn md-only no-rtf" onclick="actCodeBlock()" title="Code block">
             <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
         </button>
 
@@ -638,13 +695,14 @@
                 <div class="sub">Editor behaviour and appearance.</div>
 
                 <div class="set-row">
-                    <div><div class="set-label">Document mode</div><div class="set-desc">Markdown enables live formatting; Plain text edits raw characters.</div></div>
+                    <div><div class="set-label">Document mode</div><div class="set-desc">Markdown enables live formatting; Rich text saves as RTF (fonts, colours, alignment); Plain text edits raw characters.</div></div>
                     <div class="set-ctl"><div class="seg" id="seg-mode">
                         <button data-mode="md"  onclick="setMode('md')">Markdown</button>
+                        <button data-mode="rtf" onclick="setMode('rtf')">Rich text</button>
                         <button data-mode="txt" onclick="setMode('txt')">Plain text</button>
                     </div></div>
                 </div>
-                <div class="set-row md-only">
+                <div class="set-row md-only no-rtf">
                     <div><div class="set-label">Markdown syntax</div><div class="set-desc">Reveal the markdown marks (#, **, &gt;, …) so you can see and edit the source.</div></div>
                     <div class="set-ctl"><select class="set-select" id="set-syntax" onchange="applySyntaxMode()">
                         <option value="active">Show on selected line</option>
@@ -764,6 +822,8 @@
 var filepath        = "";
 var filename        = "";
 var isTxtMode       = false;      // false = markdown WYSIWYG, true = plain text
+var isRtfMode       = false;      // rich surface saved as RTF instead of markdown (never with isTxtMode)
+var rtfDoc          = { baseSize:24, baseFont:"" };   // defaults of the loaded .rtf, kept on save
 var isDark          = false;
 var dirtyFlag       = false;
 var dlgCallback     = null;
@@ -789,7 +849,7 @@ var FONT_MAP = {
 
 // ── Shortcuts ───────────────────────────────────────────────────────────
 var DEFAULT_KEYS = {
-    save:"ctrl+s", bold:"ctrl+b", italic:"ctrl+i", strike:"ctrl+shift+x", inlineCode:"ctrl+e",
+    save:"ctrl+s", bold:"ctrl+b", italic:"ctrl+i", underline:"ctrl+u", strike:"ctrl+shift+x", inlineCode:"ctrl+e",
     h1:"ctrl+1", h2:"ctrl+2", h3:"ctrl+3", h4:"ctrl+4", h5:"ctrl+5", h6:"ctrl+6", paragraph:"ctrl+0",
     blockquote:"ctrl+shift+q", bulletList:"ctrl+shift+u", orderedList:"ctrl+shift+o",
     codeBlock:"ctrl+shift+k", link:"ctrl+k", image:"ctrl+shift+i", hr:"ctrl+shift+h"
@@ -798,8 +858,9 @@ var ACTIONS = {
     save:      { label:"Save",            fn:saveFile,                  md:false },
     bold:      { label:"Bold",            fn:actBold,                   md:true  },
     italic:    { label:"Italic",          fn:actItalic,                 md:true  },
+    underline: { label:"Underline (rich text)", fn:actUnderline,    md:true  },
     strike:    { label:"Strikethrough",   fn:actStrike,                 md:true  },
-    inlineCode:{ label:"Inline code",     fn:actInlineCode,             md:true  },
+    inlineCode:{ label:"Inline code",     fn:actInlineCode,             md:true, rtf:false },
     h1:        { label:"Heading 1",       fn:function(){setBlock("h1");}, md:true },
     h2:        { label:"Heading 2",       fn:function(){setBlock("h2");}, md:true },
     h3:        { label:"Heading 3",       fn:function(){setBlock("h3");}, md:true },
@@ -807,10 +868,10 @@ var ACTIONS = {
     h5:        { label:"Heading 5",       fn:function(){setBlock("h5");}, md:true },
     h6:        { label:"Heading 6",       fn:function(){setBlock("h6");}, md:true },
     paragraph: { label:"Paragraph",       fn:function(){setBlock("p");},  md:true },
-    blockquote:{ label:"Blockquote",      fn:actQuote,                  md:true  },
+    blockquote:{ label:"Blockquote",      fn:actQuote,                  md:true, rtf:false },
     bulletList:{ label:"Bullet list",     fn:actBullet,                 md:true  },
     orderedList:{label:"Numbered list",   fn:actNumber,                 md:true  },
-    codeBlock: { label:"Code block",      fn:actCodeBlock,              md:true  },
+    codeBlock: { label:"Code block",      fn:actCodeBlock,              md:true, rtf:false },
     link:      { label:"Link",            fn:actLink,                   md:true  },
     image:     { label:"Insert image",    fn:function(){pickDeviceImage();}, md:true },
     hr:        { label:"Horizontal rule", fn:actHr,                     md:true  }
@@ -877,11 +938,11 @@ $(function(){
     if (files && files.length > 0){
         filepath = files[0].filepath;
         filename = files[0].filename;
-        isTxtMode = !isMarkdownExt(filename);
+        modeForFile(filename);
         applyMode();
         loadFile();
     } else {
-        isTxtMode = false;
+        isTxtMode = false; isRtfMode = false;
         applyMode();
         updateTitle();
     }
@@ -922,9 +983,10 @@ $(function(){
     });
     // keep selection alive when clicking toolbar buttons
     document.getElementById("toolbar").addEventListener("mousedown", function(e){
-        if (e.target.closest(".tb-btn") && !e.target.closest("select")) e.preventDefault();
+        if (e.target.closest(".tb-btn, .swatch") && !e.target.closest("select")) e.preventDefault();
     });
 
+    buildSwatches();
     buildKeyList();
     syncSettingsUI();
     applyTypography();
@@ -935,6 +997,16 @@ function isMarkdownExt(name){
     var ext = (name.split(".").pop() || "").toLowerCase();
     return ext === "md" || ext === "markdown" || ext === "mdown" || ext === "mkd";
 }
+function isRtfExt(name){
+    return (name.split(".").pop() || "").toLowerCase() === "rtf";
+}
+// pick the editing surface from the file extension
+function modeForFile(name){
+    isRtfMode = isRtfExt(name);
+    isTxtMode = !isRtfMode && !isMarkdownExt(name);
+}
+function modeName(){ return isTxtMode ? "txt" : (isRtfMode ? "rtf" : "md"); }
+function defaultNewName(){ return "Untitled." + modeName(); }
 
 // ════════════════════════════════════════════════════════════════════════
 // File load / save
@@ -943,6 +1015,18 @@ function loadFile(){
     // strong cache-buster: unique nonce + jQuery cache:false so the browser
     // always fetches the freshly-saved file, never a stale cached copy
     var nonce = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
+    if (isRtfMode){
+        // RTF may carry raw code-page bytes, so read it byte-for-byte rather
+        // than letting the browser decode it as UTF-8
+        fetch(ao_root + "media?file=" + encodeURIComponent(filepath) + "&nocache=" + nonce, { cache:"no-store", credentials:"same-origin" })
+            .then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); })
+            .then(function(buf){
+                setContent(bytesToBinary(new Uint8Array(buf)));
+                dirtyFlag = false;
+                updateTitle(); updateStatBar();
+            }).catch(function(){ setStatus("Failed to load file", "error"); });
+        return;
+    }
     $.ajax({
         url: ao_root + "media?file=" + encodeURIComponent(filepath) + "&nocache=" + nonce,
         method: "GET",
@@ -958,9 +1042,23 @@ function loadFile(){
     });
 }
 
+function bytesToBinary(u8){
+    var parts = [];
+    for (var i = 0; i < u8.length; i += 0x8000) parts.push(String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)));
+    return parts.join("");
+}
+
 function setContent(text){
     if (isTxtMode){
         plain.value = text;
+    } else if (isRtfMode){
+        var res;
+        try { res = TextRTF.toHTML(text || ""); }
+        catch(e){ console.error(e); setStatus("Could not read this RTF file", "error"); res = { html:"<p><br></p>", baseSize:24, baseFont:"" }; }
+        rtfDoc = { baseSize:res.baseSize, baseFont:res.baseFont };
+        rich.innerHTML = res.html;
+        refreshEmptyState();
+        updateActiveBlock();
     } else {
         rich.innerHTML = renderWithBlankRuns(text || "");
         normalizeEmptyParas(rich);
@@ -1014,6 +1112,24 @@ function normalizeEmptyParas(root){
 
 function getContent(){
     if (isTxtMode) return plain.value;
+    if (isRtfMode) return TextRTF.fromHTML(richClone(), rtfDoc);
+    return richMarkdown();
+}
+// like getContent(), but RTF first embeds every image (fetched + inlined as
+// PNG/JPEG) — use this for anything that writes the document out
+function getContentAsync(){
+    if (!isRtfMode) return Promise.resolve(getContent());
+    return TextRTF.inlineImages(richClone()).then(function(root){ return TextRTF.fromHTML(root, rtfDoc); });
+}
+// the rich surface without its editing decorations
+function richClone(){
+    var src = rich.cloneNode(true);
+    var syns = src.querySelectorAll(".md-imgsyn, .md-linksyn, .md-langsel");
+    for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
+    return src;
+}
+// the rich surface as markdown (also feeds PDF export in rich-text mode)
+function richMarkdown(){
     var src = rich.cloneNode(true);
     var syns = src.querySelectorAll(".md-imgsyn, .md-linksyn, .md-langsel");
     for (var i = 0; i < syns.length; i++) syns[i].parentNode.removeChild(syns[i]);
@@ -1089,7 +1205,7 @@ function richPlainText(){
 
 function saveFile(){
     if (!filepath){
-        var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
+        var def = defaultNewName();
         ao_module_openFileSelector(handleSaveAs, "user:/Desktop", "new", false, { defaultName: def });
         return;
     }
@@ -1101,26 +1217,27 @@ function handleSaveAs(fd){
     doSave();
 }
 function doSave(callback){
-    var content = getContent();
-    ao_module_agirun("Text/backend/filesaver.agi", { filepath: filepath, content: content }, function(data){
-        if (data && data.error){
-            setStatus("Save failed: " + data.error, "error");
-        } else {
-            dirtyFlag = false; updateTitle(); setStatus("Saved");
-            if (callback) callback();
-        }
-    }, function(){ setStatus("Save failed", "error"); });
+    getContentAsync().then(function(content){
+        ao_module_agirun("Text/backend/filesaver.agi", { filepath: filepath, content: content }, function(data){
+            if (data && data.error){
+                setStatus("Save failed: " + data.error, "error");
+            } else {
+                dirtyFlag = false; updateTitle(); setStatus("Saved");
+                if (callback) callback();
+            }
+        }, function(){ setStatus("Save failed", "error"); });
+    }).catch(function(err){ console.error(err); setStatus("Save failed", "error"); });
 }
 
 function openFile(){
     ao_module_openFileSelector(handleOpenFile, "user:/", "file", false, {
-        filter: ["txt","md","markdown","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
+        filter: ["txt","md","markdown","rtf","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
     });
 }
 function handleOpenFile(fd){
     if (!fd || !fd.length) return;
     filepath = fd[0].filepath; filename = fd[0].filename;
-    isTxtMode = !isMarkdownExt(filename);
+    modeForFile(filename);
     applyMode();
     dirtyFlag = false;
     loadFile();
@@ -1131,23 +1248,46 @@ function handleOpenFile(fd){
 // ════════════════════════════════════════════════════════════════════════
 function applyMode(){
     document.body.classList.toggle("mode-txt", isTxtMode);
-    $("#stat-mode").text(isTxtMode ? "Plain text" : "Markdown");
-    $("#mi-download-lbl").text(isTxtMode ? "Download .txt" : "Download .md");
+    document.body.classList.toggle("mode-rtf", isRtfMode);
+    $("#stat-mode").text(isTxtMode ? "Plain text" : (isRtfMode ? "Rich text" : "Markdown"));
+    $("#mi-download-lbl").text("Download ." + modeName());
+    $("#rich").attr("data-ph", isRtfMode ? "Start writing…" : "Start writing… type # for a heading, ** for bold");
     $("#seg-mode button").removeClass("active");
-    $('#seg-mode button[data-mode="' + (isTxtMode ? "txt" : "md") + '"]').addClass("active");
+    $('#seg-mode button[data-mode="' + modeName() + '"]').addClass("active");
     applyTypography();
     updateActiveBlock();
 }
 
 // invoked from the settings segmented control — converts current content
 function setMode(mode){
-    var wantTxt = (mode === "txt");
-    if (wantTxt === isTxtMode) return;
-    var current = getContent();           // serialise from the current surface
-    isTxtMode = wantTxt;
-    applyMode();
-    setContent(current);                  // re-hydrate into the new surface
-    markDirty(); updateStatBar();
+    var from = modeName();
+    if (mode === from) return;
+    function to(m){ isTxtMode = (m === "txt"); isRtfMode = (m === "rtf"); applyMode(); }
+    if (from !== "txt" && mode !== "txt"){
+        // markdown <-> rich text share the same WYSIWYG surface: keep the DOM
+        if (mode === "rtf"){ clearDecorations(); clearLangSelectors(); tidyRichForRtf(); }
+        to(mode);
+        markDirty(); updateStatBar();
+        return;
+    }
+    getContentAsync().then(function(current){    // serialise from the current surface
+        to(mode);
+        setContent(current);                     // re-hydrate into the new surface
+        markDirty(); updateStatBar();
+    });
+}
+// marked's HTML carries layout newlines (between blocks, soft line breaks);
+// rich-text mode shows whitespace literally, so flatten them first
+function tidyRichForRtf(){
+    var walker = document.createTreeWalker(rich, NodeFilter.SHOW_TEXT, null, false);
+    var n, dead = [];
+    while ((n = walker.nextNode())){
+        if (n.parentNode.closest && n.parentNode.closest("pre")) continue;
+        if (n.nodeValue.indexOf("\n") < 0) continue;
+        if (/^\s*$/.test(n.nodeValue)) dead.push(n);
+        else n.nodeValue = n.nodeValue.replace(/\s*\n\s*/g, " ");
+    }
+    dead.forEach(function(d){ d.parentNode.removeChild(d); });
 }
 
 // ════════════════════════════════════════════════════════════════════════
@@ -1732,6 +1872,7 @@ function exec(cmd, val){
 function actBold(){   if(isTxtMode) return; exec("bold"); }
 function actItalic(){ if(isTxtMode) return; exec("italic"); }
 function actStrike(){ if(isTxtMode) return; exec("strikeThrough"); }
+function actUnderline(){ if(isRtfMode) exec("underline"); }   // markdown has no underline
 function actBullet(){ if(isTxtMode) return; exec("insertUnorderedList"); }
 function actNumber(){ if(isTxtMode) return; exec("insertOrderedList"); }
 function actInlineCode(){ if(isTxtMode) return; wrapSelection("code"); }
@@ -1853,6 +1994,106 @@ function onHeadingSelect(){
     setBlock($("#sel-head").val());
 }
 
+// ── Rich text (RTF) formatting ──────────────────────────────────────────
+var FORE_SWATCHES = ["#000000","#595959","#a6a6a6","#c00000","#e36c09","#bf8f00",
+                     "#00b050","#0f7c80","#0070c0","#002060","#7030a0","#d6337a"];
+var BACK_SWATCHES = ["#ffff00","#00ff00","#00ffff","#ff00ff","#ffc000","#ff9999",
+                     "#b4c7e7","#c5e0b4","#d9d9d9","#a9d18e","#f4b183","#ffe699"];
+var AUTO_SENTINEL = "#010203";         // marks "automatic colour" runs for clean-up
+function buildSwatches(){
+    function fill(id, list, kind, noneLabel){
+        var html = '<button class="menu-item swatch-none" onclick="hideMenus();actColor(\'' + kind + '\', null)">' + noneLabel + '</button><div class="swatch-grid">';
+        list.forEach(function(c){
+            html += '<button class="swatch" title="' + c + '" style="background:' + c + ';" onclick="hideMenus();actColor(\'' + kind + '\', \'' + c + '\')"></button>';
+        });
+        document.getElementById(id).innerHTML = html + "</div>";
+    }
+    fill("fore-menu", FORE_SWATCHES, "fore", "Automatic");
+    fill("back-menu", BACK_SWATCHES, "back", "No highlight");
+}
+// text / highlight colour of the selection; null = automatic / none
+function actColor(kind, color){
+    if (!isRtfMode) return;
+    rich.focus();
+    try { document.execCommand("styleWithCSS", false, true); } catch(e){}
+    if (kind === "fore"){
+        document.execCommand("foreColor", false, color || AUTO_SENTINEL);
+        if (color) $("#fore-bar").css("background", color);
+        else stripSentinelColor();
+    } else {
+        document.execCommand("hiliteColor", false, color || "transparent");
+    }
+    try { document.execCommand("styleWithCSS", false, false); } catch(e){}
+    markDirty(); updateStatBar();
+}
+function stripSentinelColor(){
+    $(rich).find('[style*="rgb(1, 2, 3)"], font[color="' + AUTO_SENTINEL + '"]').each(function(){
+        this.style.color = "";
+        this.removeAttribute("color");
+        if ((this.tagName === "SPAN" || this.tagName === "FONT") && !this.getAttribute("style") && this.attributes.length <= 1) $(this).contents().unwrap();
+    });
+}
+function actAlign(dir){
+    if (!isRtfMode) return;
+    exec("justify" + dir);
+}
+// font size in points; a collapsed caret gets an empty sized span to type into
+function actFontSize(){
+    var pt = $("#sel-size").val();
+    if (!isRtfMode || !pt) return;
+    rich.focus();
+    var sel = getSel();
+    if (!sel.rangeCount || !rich.contains(sel.anchorNode)) return;
+    if (sel.isCollapsed){
+        var span = document.createElement("span");
+        span.style.fontSize = pt + "pt";
+        span.appendChild(document.createTextNode("​"));
+        var r = sel.getRangeAt(0);
+        r.insertNode(span);
+        var nr = document.createRange(); nr.setStart(span.firstChild, 1); nr.collapse(true);
+        sel.removeAllRanges(); sel.addRange(nr);
+    } else {
+        document.execCommand("fontSize", false, "7");
+        $(rich).find('font[size="7"]').each(function(){
+            var sp = document.createElement("span");
+            sp.style.fontSize = pt + "pt";
+            while (this.firstChild) sp.appendChild(this.firstChild);
+            // the new size wins over any size set inside the selection
+            $(sp).find("[style]").each(function(){ this.style.fontSize = ""; });
+            $(sp).find("font[size]").each(function(){ this.removeAttribute("size"); });
+            this.parentNode.replaceChild(sp, this);
+        });
+    }
+    markDirty(); updateStatBar();
+}
+// image picked / pasted / dropped in rich-text mode: embed it in the document
+function insertEmbeddedImage(file){
+    prepareBlob(file, function(blob){
+        var fr = new FileReader();
+        fr.onload = function(){
+            rich.focus();
+            document.execCommand("insertHTML", false, '<img src="' + escapeAttr(fr.result) + '" alt="">');
+            refreshEmptyState(); markDirty(); setStatus("Image inserted");
+        };
+        fr.onerror = function(){ setStatus("Could not read the image", "error"); };
+        fr.readAsDataURL(blob);
+    });
+}
+function updateRtfToolbarState(){
+    try { $("#btn-underline").toggleClass("active", document.queryCommandState("underline")); } catch(e){}
+    var n = getSel().anchorNode;
+    if (n && n.nodeType === 3) n = n.parentNode;
+    if (!n || !rich.contains(n)) return;
+    // queryCommandState("justify…") is unreliable, so read the block's alignment
+    var b = currentBlock();
+    var ta = b ? getComputedStyle(b).textAlign : "left";
+    var cur = ta === "center" ? "center" : (ta === "right" || ta === "end") ? "right" : ta === "justify" ? "full" : "left";
+    ["left","center","right","full"].forEach(function(d){ $("#btn-align-" + d).toggleClass("active", d === cur); });
+    var pt = Math.round(parseFloat(getComputedStyle(n).fontSize) * 0.75 * 2) / 2;
+    var opt = $('#sel-size option').filter(function(){ return this.value === String(pt); });
+    $("#sel-size").val(opt.length ? String(pt) : "");
+}
+
 function wrapSelection(tag){
     rich.focus();
     var sel = getSel();
@@ -1884,6 +2125,7 @@ function updateToolbarState(){
     if (!/^h[1-6]$/.test(tag)) tag = "p";
     $("#sel-head").val(tag);
     $("#btn-code").toggleClass("active", !!(getSel().anchorNode && getSel().anchorNode.parentNode && getSel().anchorNode.parentNode.closest("code")));
+    if (isRtfMode) updateRtfToolbarState();
 }
 
 // ── Markdown source-mark reveal ─────────────────────────────────────────
@@ -1944,7 +2186,7 @@ function updateActiveBlock(){
     if (isTxtMode) return;
     if (activeImgSyn() || activeLinkSyn()) return;   // never tear down a mark being edited
     clearDecorations();
-    if (settings.syntax === "none") return;             // Always hide
+    if (settings.syntax === "none" || isRtfMode) return;   // Always hide / no markdown marks in RTF
     if (settings.syntax === "always"){
         var all = rich.querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,blockquote");
         for (var j = 0; j < all.length; j++) decorateBlock(all[j]);
@@ -2078,6 +2320,7 @@ function globalKeydown(e){
             var def = ACTIONS[action];
             if (!def) continue;
             if (def.md && isTxtMode) continue;    // markdown-only action in txt mode
+            if (def.rtf === false && isRtfMode) continue;   // no RTF equivalent
             if (def.md && (activeImgSyn() || activeLinkSyn())) continue; // don't format inside a source mark
             e.preventDefault();
             def.fn();
@@ -2188,13 +2431,14 @@ function ensureImgDir(cb){
 
 function pickDeviceImage(){
     if (isTxtMode) return;
-    if (!filepath){ alert("Please save the document first."); return; }
+    if (!filepath && !isRtfMode){ alert("Please save the document first."); return; }
     document.getElementById("device-file").click();
 }
 function onDeviceFileChosen(e){
     var file = e.target.files[0];
     e.target.value = "";
     if (!file) return;
+    if (isRtfMode){ insertEmbeddedImage(file); return; }
     ensureImgDir(function(rel){
         prepareBlob(file, function(blob, ext){
             var fname = makeImgName(file.name, ext);
@@ -2233,7 +2477,7 @@ function prepareBlob(file, cb){
 
 function pickServerImage(){
     if (isTxtMode) return;
-    if (!filepath){ alert("Please save the document first."); return; }
+    if (!filepath && !isRtfMode){ alert("Please save the document first."); return; }
     ao_module_openFileSelector(handleServerImage, "user:/", "file", false, {
         filter: ["jpg","jpeg","png","gif","webp","bmp","svg"]
     });
@@ -2241,6 +2485,14 @@ function pickServerImage(){
 function handleServerImage(fd){
     if (!fd || !fd.length) return;
     var src = fd[0].filepath, name = fd[0].filename;
+    if (isRtfMode){
+        setStatus("Importing image…");
+        fetch(ao_root + "media?file=" + encodeURIComponent(src), { credentials:"same-origin" })
+            .then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.blob(); })
+            .then(function(b){ insertEmbeddedImage(new File([b], name, { type:b.type })); })
+            .catch(function(){ setStatus("Image import failed", "error"); });
+        return;
+    }
     var rel = relDir();
     var dest = makeImgName(name, settings.compress ? "jpg" : null);
     setStatus("Importing image…");
@@ -2300,6 +2552,7 @@ function onRichDrop(e){
     }
 }
 function handleDroppedImage(file){
+    if (isRtfMode){ insertEmbeddedImage(file); return; }
     if (!filepath){ alert("Please save the document first to store pasted images."); return; }
     ensureImgDir(function(rel){
         prepareBlob(file, function(blob, ext){
@@ -2318,6 +2571,7 @@ function handleDroppedImage(file){
 // ════════════════════════════════════════════════════════════════════════
 function renderedHTML(){
     if (isTxtMode) return "<pre>" + escapeHtml(plain.value) + "</pre>";
+    if (isRtfMode) return richClone().innerHTML.replace(/​/g, "");
     return renderWithBlankRuns(getContent());
 }
 
@@ -2437,6 +2691,15 @@ function collectDocImages(){
 }
 
 function exportDocument(){
+    if (isRtfMode){                       // images are embedded in the RTF itself
+        showBusy("Building document…");
+        getContentAsync().then(function(rtf){
+            hideBusy();
+            downloadBlob(new Blob([rtf], { type:"application/rtf" }), docName() + ".rtf");
+            setStatus("Downloaded " + docName() + ".rtf");
+        }).catch(function(){ hideBusy(); setStatus("Export failed", "error"); });
+        return;
+    }
     var content = getContent();
     var ext  = isTxtMode ? "txt" : "md";
     var name = docName() + "." + ext;
@@ -2864,7 +3127,7 @@ function pdfBuild(){
             });
             return chain.then(function(){ return pdf.save(); });
         }
-        return pdfRenderTokens(P, marked.lexer(getContent())).then(function(){ return pdf.save(); });
+        return pdfRenderTokens(P, marked.lexer(isRtfMode ? richMarkdown() : getContent())).then(function(){ return pdf.save(); });
     });
 }
 
@@ -2896,7 +3159,7 @@ function syncSettingsUI(){
     $("#quality-val").text(settings.quality + "%");
     $("#set-maxw").val(settings.maxWidth);
     $("#seg-mode button").removeClass("active");
-    $('#seg-mode button[data-mode="'+(isTxtMode?"txt":"md")+'"]').addClass("active");
+    $('#seg-mode button[data-mode="'+modeName()+'"]').addClass("active");
     onCompressToggle(true);
 }
 
@@ -3047,7 +3310,7 @@ function dlgDiscard(){ var cb=dlgCallback; hideDialog(); dlgCallback=null; if(cb
 function dlgSave(){
     pendingClose = dlgCallback; dlgCallback = null; hideDialog();
     if (!filepath){
-        var def = isTxtMode ? "Untitled.txt" : "Untitled.md";
+        var def = defaultNewName();
         ao_module_openFileSelector(handleDlgSaveAs, "user:/Desktop", "new", false, { defaultName: def });
     } else {
         doSave(function(){ if (pendingClose){ pendingClose("saved"); pendingClose=null; } });

+ 1 - 1
src/web/Text/init.agi

@@ -15,7 +15,7 @@ var moduleLaunchInfo = {
     LaunchFWDir: "Text/index.html",
     SupportEmb: false,
     InitFWSize: [940, 620],
-    SupportedExt: [".txt", ".md", ".markdown", ".mdown", ".mkd", ".csv", ".log", ".ini", ".conf"]
+    SupportedExt: [".txt", ".md", ".markdown", ".mdown", ".mkd", ".rtf", ".csv", ".log", ".ini", ".conf"]
 }
 
 registerModule(JSON.stringify(moduleLaunchInfo));

+ 1001 - 0
src/web/Text/rtf.js

@@ -0,0 +1,1001 @@
+/*
+    rtf.js — self-contained RTF reader / writer for the Text editor.
+
+    TextRTF.toHTML(src)
+        src is the RTF source as a "binary string" (one char per byte, as read
+        from the file); characters above U+00FF are accepted as already-decoded
+        text. Returns { html, baseSize, baseFont } where baseSize is the
+        document's dominant font size in half-points and baseFont its dominant
+        font name. Runs at the base size / font carry no inline style, so they
+        follow the editor's own typography.
+
+    TextRTF.fromHTML(root, opts)
+        Serialises the editor DOM (a detached clone) back to RTF. opts may give
+        { baseSize, baseFont } so a document keeps the defaults it was loaded
+        with. Images must be data: URLs (run inlineImages() first).
+
+    TextRTF.inlineImages(root)
+        Promise. Turns every <img> under root into a PNG/JPEG data: URL and
+        records its natural size, so fromHTML() can embed it as \pict.
+
+    Supported: paragraphs, alignment, indents, headings (\outlinelevel), bold,
+    italic, underline, strikethrough, super/subscript, fonts, sizes, colours,
+    highlight, hyperlinks, bullet / numbered lists, tables, horizontal rules,
+    PNG/JPEG pictures, \u unicode and code-page (\'hh) text incl. DBCS (Big5,
+    GBK, Shift_JIS, EUC-KR).
+*/
+(function(){
+"use strict";
+
+// ── Code pages ───────────────────────────────────────────────────────────
+var CHARSET_CP = {
+    0:1252, 77:10000, 128:932, 129:949, 130:949, 134:936, 136:950, 161:1253,
+    162:1254, 163:1258, 177:1255, 178:1256, 186:1257, 204:1251, 222:874, 238:1250
+};
+var CP_LABEL = {
+    874:"windows-874", 932:"shift_jis", 936:"gbk", 949:"euc-kr", 950:"big5",
+    1250:"windows-1250", 1251:"windows-1251", 1252:"windows-1252", 1253:"windows-1253",
+    1254:"windows-1254", 1255:"windows-1255", 1256:"windows-1256", 1257:"windows-1257",
+    1258:"windows-1258", 10000:"macintosh", 65001:"utf-8"
+};
+var decoders = {};
+function decoderFor(cp){
+    if (decoders[cp]) return decoders[cp];
+    var d = null;
+    try { d = new TextDecoder(CP_LABEL[cp] || "windows-1252"); } catch(e){}
+    if (!d) d = new TextDecoder("windows-1252");
+    decoders[cp] = d;
+    return d;
+}
+
+// destinations whose whole group is dropped
+var SKIP_DEST = {
+    stylesheet:1, info:1, header:1, headerl:1, headerr:1, headerf:1, footer:1, footerl:1,
+    footerr:1, footerf:1, footnote:1, nonshppict:1, listtable:1, listoverridetable:1,
+    revtbl:1, rsidtbl:1, xe:1, tc:1, txe:1, themedata:1, colorschememapping:1,
+    latentstyles:1, datastore:1, xmlnstbl:1, mmathPr:1, pnseclvl:1, filetbl:1,
+    pgdsctbl:1, author:1, operator:1, title:1, comment:1, company:1, doccomm:1,
+    keywords:1, subject:1, upr:1, "private":1, annotation:1, atnid:1, atnauthor:1,
+    objdata:1, objclass:1, bkmkstart:1, bkmkend:1, template:1, docvar:1, userprops:1
+};
+// "\*"-marked destinations we understand (every other one is skipped)
+var KNOWN_STAR = { fldinst:1, shppict:1, pn:1, listtext:1 };
+
+var SYMBOLS = {
+    emdash:"\u2014", endash:"\u2013", bullet:"\u2022", lquote:"\u2018", rquote:"\u2019",
+    ldblquote:"\u201C", rdblquote:"\u201D", emspace:"\u2003", enspace:"\u2002",
+    qmspace:"\u2005", zwj:"\u200D", zwnj:"\u200C", ltrmark:"", rtlmark:""
+};
+
+function defaultChar(){
+    return { b:false, i:false, u:false, s:false, sup:false, sub:false, v:false,
+             fs:24, f:-1, cf:0, bg:0, link:null };
+}
+function defaultPara(){
+    return { align:"", li:0, fi:0, intbl:false, outline:-1, ilvl:-1, ls:0, brdrb:false };
+}
+function copy(o){ var r = {}; for (var k in o) r[k] = o[k]; return r; }
+
+// ════════════════════════════════════════════════════════════════════════
+// Reader
+// ════════════════════════════════════════════════════════════════════════
+function parse(src){
+    var fonts = {}, colors = [], deff = 0, ansicp = 1252;
+    var blocks = [];                    // finished paragraphs / tables
+    var table = null, row = null, cell = null;
+    var para = newPara();
+
+    var st = { dest:"main", chr:defaultChar(), par:defaultPara(), uc:1, field:null,
+               font:null, pict:null };
+    var stack = [];
+    var bytes = [], bytesCP = 1252;     // pending code-page bytes
+    var skipChars = 0;                  // \u fallback characters still to skip
+    var star = false;                   // saw "\*" for the next control word
+    var color = null;                   // colortbl entry being built
+
+    function newPara(){ return { runs:[], marker:"", listType:"" }; }
+
+    function fontCP(fi){ return cpOf(fonts[fi]); }
+    function cpOf(f){
+        if (!f) return ansicp;
+        if (f.cpg) return f.cpg;
+        if (f.charset === 1 || f.charset === undefined) return ansicp;
+        if (f.charset === 2) return 1252;
+        return CHARSET_CP[f.charset] || ansicp;
+    }
+    function curCP(){ return fontCP(st.chr.f < 0 ? deff : st.chr.f); }
+
+    function flush(){
+        if (!bytes.length) return;
+        var txt = decoderFor(bytesCP).decode(new Uint8Array(bytes));
+        bytes = [];
+        emitText(txt);
+    }
+    function emitByte(b){
+        if (skipChars > 0){ skipChars--; return; }
+        var cp = st.dest === "fonttbl" ? cpOf(st.font) : curCP();
+        if (bytes.length && cp !== bytesCP) flush();
+        bytesCP = cp;
+        bytes.push(b);
+    }
+    function emitText(txt){
+        if (!txt) return;
+        switch (st.dest){
+        case "main":
+            if (st.chr.v) return;                       // hidden text
+            var runs = para.runs, last = runs[runs.length - 1];
+            if (last && last.type === "text" && sameChr(last.chr, st.chr)) last.text += txt;
+            else runs.push({ type:"text", text:txt, chr:copy(st.chr) });
+            return;
+        case "fonttbl":
+            if (st.font) st.font.raw += txt;
+            return;
+        case "fldinst":
+            if (st.field) st.field.inst += txt;
+            return;
+        case "listtext":
+            para.marker += txt;
+            return;
+        }
+    }
+    function sameChr(a, b){
+        for (var k in a) if (a[k] !== b[k]) return false;
+        return true;
+    }
+    function emitObj(o){
+        if (st.dest !== "main" || st.chr.v) return;
+        o.chr = copy(st.chr);
+        para.runs.push(o);
+    }
+
+    function endPara(){
+        flush();
+        var p = para;
+        p.pp = copy(st.par);
+        para = newPara();
+        if (p.pp.intbl){
+            if (!cell) cell = [];
+            cell.push(p);
+        } else {
+            closeTable();
+            blocks.push(p);
+        }
+    }
+    function endCell(){
+        flush();
+        if (para.runs.length || !cell || !cell.length){
+            para.pp = copy(st.par);
+            if (!cell) cell = [];
+            cell.push(para);
+        }
+        para = newPara();
+        if (!row) row = [];
+        row.push(cell);
+        cell = null;
+    }
+    function endRow(){
+        flush();
+        if (cell) endCell();
+        if (!table) table = { type:"table", rows:[] };
+        if (row) table.rows.push(row);
+        row = null;
+    }
+    function closeTable(){
+        if (cell || row) endRow();
+        if (table){ blocks.push(table); table = null; }
+    }
+
+    function endGroup(){
+        flush();
+        var child = st;
+        st = stack.pop() || st;
+        if (child.dest === "pict" && child.pict && st.dest !== "skip") finishPict(child);
+        if (child.dest === "fonttbl" && child.font && child.font !== st.font) finishFont(child.font);
+        skipChars = 0;
+    }
+    function finishFont(f){
+        if (f.done) return;
+        f.done = true;
+        var name = f.raw.replace(/;.*$/, "").trim();
+        fonts[f.idx] = { name:name, charset:f.charset, cpg:f.cpg, family:f.family };
+    }
+    function finishPict(s){
+        var p = s.pict, mime = null;
+        if (p.type === "png") mime = "image/png";
+        else if (p.type === "jpeg") mime = "image/jpeg";
+        if (!mime) return;                              // metafiles / bitmaps can't be shown
+        var bin = p.bin;
+        if (!bin){
+            var hex = p.hex.replace(/[^0-9a-fA-F]/g, "");
+            var out = [];
+            for (var i = 0; i + 1 < hex.length; i += 2) out.push(String.fromCharCode(parseInt(hex.substr(i, 2), 16)));
+            bin = out.join("");
+        }
+        if (!bin) return;
+        var w = p.wgoal ? p.wgoal * (p.scalex || 100) / 100 / 15 : 0;
+        var h = p.hgoal ? p.hgoal * (p.scaley || 100) / 100 / 15 : 0;
+        if (st.dest === "main"){                        // st is the enclosing group again
+            para.runs.push({ type:"img", src:"data:" + mime + ";base64," + btoa(bin),
+                             w:Math.round(w), h:Math.round(h), chr:copy(st.chr) });
+        }
+    }
+
+    function control(word, param, hasParam){
+        // "\*" handling: unknown ignorable destinations are skipped wholesale
+        if (star){
+            star = false;
+            if (!KNOWN_STAR[word]){ st.dest = "skip"; return; }
+        }
+        if (st.dest === "skip") return;
+        if (skipChars > 0 && word !== "u"){ skipChars--; return; }
+
+        if (SKIP_DEST[word]){ st.dest = "skip"; return; }
+
+        // unicode text is valid in every text-bearing destination (incl. font names)
+        if (word === "u"){
+            flush();
+            emitText(String.fromCharCode(param < 0 ? param + 65536 : param));
+            skipChars = st.uc;
+            return;
+        }
+        if (word === "uc"){ st.uc = param; return; }
+
+        // ── destinations ──
+        switch (word){
+        case "fonttbl":  st.dest = "fonttbl"; st.font = null; return;
+        case "colortbl": st.dest = "colortbl"; color = null; return;
+        case "pict":     st.dest = "pict"; st.pict = { type:"", hex:"", bin:"", wgoal:0, hgoal:0, scalex:100, scaley:100 }; return;
+        case "field":    st.field = { inst:"" }; return;
+        case "fldinst":  st.dest = "fldinst"; return;
+        case "fldrslt":
+            st.dest = "main";
+            if (st.field){
+                var m = st.field.inst.match(/HYPERLINK\s+(?:\\l\s+)?"([^"]*)"/i) ||
+                        st.field.inst.match(/HYPERLINK\s+(\S+)/i);
+                if (m){
+                    var url = m[1];
+                    if (/\\l\s/.test(st.field.inst) && url.charAt(0) !== "#") url = "#" + url;
+                    st.chr.link = url;
+                }
+            }
+            return;
+        case "listtext": case "pntext":
+            st.dest = "listtext"; return;
+        case "pn":       st.dest = "pn"; return;
+        case "shppict":  return;                        // its \pict is the real picture
+        }
+
+        if (st.dest === "fonttbl"){
+            if (word === "f"){
+                if (st.font && !st.font.done && stack.length && stack[stack.length-1].font !== st.font) finishFont(st.font);
+                st.font = { idx:param, raw:"", charset:undefined, cpg:0, family:"" };
+            } else if (st.font){
+                if (word === "fcharset") st.font.charset = param;
+                else if (word === "cpg") st.font.cpg = param;
+                else if (/^f(roman|swiss|modern|script|decor|tech|bidi|nil)$/.test(word)) st.font.family = word.substr(1);
+            }
+            return;
+        }
+        if (st.dest === "colortbl"){
+            if (!color) color = { r:0, g:0, b:0 };
+            if (word === "red") color.r = param;
+            else if (word === "green") color.g = param;
+            else if (word === "blue") color.b = param;
+            return;
+        }
+        if (st.dest === "pict"){
+            var p = st.pict;
+            if (word === "pngblip") p.type = "png";
+            else if (word === "jpegblip") p.type = "jpeg";
+            else if (word === "emfblip" || word === "wmetafile" || word === "macpict" || word === "dibitmap" || word === "wbitmap") p.type = p.type || word;
+            else if (word === "picwgoal") p.wgoal = param;
+            else if (word === "pichgoal") p.hgoal = param;
+            else if (word === "picscalex") p.scalex = param;
+            else if (word === "picscaley") p.scaley = param;
+            return;
+        }
+        if (st.dest === "pn"){
+            if (word === "pnlvlblt") para.listType = "ul";
+            else if (/^pn(lvlbody|dec|ucltr|lcltr|ucrm|lcrm)$/.test(word)) para.listType = para.listType || "ol";
+            return;
+        }
+
+        var c = st.chr, pp = st.par;
+        switch (word){
+        // header
+        case "ansicpg": ansicp = param || 1252; return;
+        case "deff":    deff = param; return;
+        // breaks
+        case "par": case "sect": case "page":
+            endPara(); return;
+        case "line":
+            flush(); emitObj({ type:"br" }); return;
+        case "tab":
+            flush(); emitText("\t"); return;
+        case "cell": case "nestcell":
+            endCell(); return;
+        case "row": case "nestrow":
+            endRow(); return;
+        // paragraph formatting
+        case "pard":  st.par = defaultPara(); return;
+        case "ql":    pp.align = ""; return;
+        case "qc":    pp.align = "center"; return;
+        case "qr":    pp.align = "right"; return;
+        case "qj":    pp.align = "justify"; return;
+        case "li":    pp.li = param; return;
+        case "fi":    pp.fi = param; return;
+        case "intbl": pp.intbl = true; return;
+        case "outlinelevel": pp.outline = param; return;
+        case "ilvl":  pp.ilvl = param; return;
+        case "ls":    pp.ls = param; return;
+        case "brdrb": pp.brdrb = true; return;
+        // character formatting
+        case "plain":
+            var link = c.link;
+            st.chr = defaultChar(); st.chr.link = link; return;
+        case "b":      c.b = !hasParam || param !== 0; return;
+        case "i":      c.i = !hasParam || param !== 0; return;
+        case "strike": case "striked":
+                       c.s = !hasParam || param !== 0; return;
+        case "ulnone": c.u = false; return;
+        case "super":  c.sup = true; c.sub = false; return;
+        case "sub":    c.sub = true; c.sup = false; return;
+        case "nosupersub": c.sup = c.sub = false; return;
+        case "v":      c.v = !hasParam || param !== 0; return;
+        case "fs":     c.fs = hasParam ? param : 24; return;
+        case "f":      c.f = param; return;
+        case "cf":     c.cf = param; return;
+        case "cb": case "highlight": case "chcbpat":
+                       c.bg = param; return;
+        }
+        if (/^ul(d|dash|dashd|dashdd|db|hwave|ldash|th|thd|thdash|thdashd|thdashdd|thldash|w|wave)?$/.test(word)){
+            c.u = !hasParam || param !== 0;
+            return;
+        }
+        if (SYMBOLS.hasOwnProperty(word)){ flush(); emitText(SYMBOLS[word]); }
+    }
+
+    // ── tokenizer ──
+    var i = 0, n = src.length;
+    while (i < n){
+        var ch = src.charAt(i), code = src.charCodeAt(i);
+        if (ch === "{"){
+            flush();
+            stack.push(st);
+            st = { dest:st.dest, chr:copy(st.chr), par:copy(st.par), uc:st.uc, field:st.field,
+                   font:st.font, pict:st.pict };
+            i++;
+            continue;
+        }
+        if (ch === "}"){
+            endGroup(); star = false; i++;
+            continue;
+        }
+        if (ch === "\\"){
+            var nx = src.charAt(i + 1);
+            if (/[a-zA-Z]/.test(nx)){
+                var j = i + 1;
+                while (j < n && /[a-zA-Z]/.test(src.charAt(j))) j++;
+                var word = src.substring(i + 1, j);
+                var k = j;
+                if (src.charAt(k) === "-" && /[0-9]/.test(src.charAt(k + 1))) k++;
+                while (k < n && /[0-9]/.test(src.charAt(k))) k++;
+                var hasParam = k > j;
+                var param = hasParam ? parseInt(src.substring(j, k), 10) : 0;
+                if (src.charAt(k) === " ") k++;
+                i = k;
+                if (word !== "u") flush();
+                if (word === "bin"){                     // raw binary data follows
+                    var data = src.substr(i, param);
+                    if (st.dest === "pict" && st.pict) st.pict.bin = data;
+                    i += param;
+                    continue;
+                }
+                control(word, param, hasParam);
+                continue;
+            }
+            i += 2;
+            switch (nx){
+            case "'":
+                var hb = parseInt(src.substr(i, 2), 16);
+                i += 2;
+                if (st.dest === "skip" || st.dest === "pn" || st.dest === "pict" || isNaN(hb)) break;
+                if (st.dest === "colortbl") break;
+                emitByte(hb);
+                break;
+            case "*": star = true; break;
+            case "~": flush(); if (skipChars > 0) skipChars--; else emitText("\u00A0"); break;
+            case "_": flush(); if (skipChars > 0) skipChars--; else emitText("\u2011"); break;
+            case "-": break;                             // optional hyphen
+            case "\n": case "\r": flush(); if (st.dest === "main") endPara(); break;
+            case "\\": case "{": case "}":
+                if (st.dest === "skip" || st.dest === "pn") break;
+                emitByte(nx.charCodeAt(0)); break;
+            }
+            continue;
+        }
+        i++;
+        if (ch === "\r" || ch === "\n") continue;
+        if (st.dest === "skip" || st.dest === "pn") continue;
+        if (st.dest === "pict"){ st.pict.hex += ch; continue; }
+        if (st.dest === "colortbl"){
+            if (ch === ";"){ colors.push(color); color = null; }
+            continue;
+        }
+        if (st.dest === "fonttbl" && ch === ";"){
+            flush();
+            if (st.font){ st.font.raw += ";"; finishFont(st.font); }
+            continue;
+        }
+        if (code > 0xFF){ flush(); if (skipChars > 0) skipChars--; else emitText(ch); continue; }
+        emitByte(code);
+    }
+    flush();
+    if (para.runs.length) endPara();
+    closeTable();
+    return { blocks:blocks, fonts:fonts, colors:colors, deff:deff };
+}
+
+// ── model → HTML ─────────────────────────────────────────────────────────
+function esc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
+function escAttr(s){ return esc(s).replace(/"/g, "&quot;"); }
+function colorCss(c){
+    if (!c) return "";
+    function h(v){ return ("0" + (v & 255).toString(16)).slice(-2); }
+    return "#" + h(c.r) + h(c.g) + h(c.b);
+}
+var GENERIC = { roman:"serif", swiss:"sans-serif", modern:"monospace", script:"cursive", decor:"fantasy" };
+
+function toHTML(src){
+    src = src || "";
+    if (!/^\s*\{\\rtf/.test(src)){
+        // not RTF (e.g. a brand-new empty file) — show it as plain paragraphs
+        var lines = src.replace(/\r\n?/g, "\n").split("\n");
+        return { html: lines.map(function(l){ return "<p>" + (l ? esc(l) : "<br>") + "</p>"; }).join(""),
+                 baseSize:24, baseFont:"" };
+    }
+    var doc = parse(src);
+
+    // dominant size / font become the document base (no inline style)
+    var sizeW = {}, fontW = {};
+    doc.blocks.forEach(function scan(b){
+        if (b.type === "table"){ b.rows.forEach(function(r){ r.forEach(function(c){ c.forEach(scan); }); }); return; }
+        if (b.pp && b.pp.outline >= 0 && b.pp.outline <= 5) return;
+        b.runs.forEach(function(r){
+            if (r.type !== "text") return;
+            var len = r.text.length, f = r.chr.f < 0 ? doc.deff : r.chr.f;
+            sizeW[r.chr.fs] = (sizeW[r.chr.fs] || 0) + len;
+            fontW[f] = (fontW[f] || 0) + len;
+        });
+    });
+    function top(w, d){ var best = d, bw = -1; for (var k in w) if (w[k] > bw){ bw = w[k]; best = +k; } return best; }
+    var baseSize = top(sizeW, 24), baseFontIdx = top(fontW, doc.deff);
+    var baseFont = doc.fonts[baseFontIdx] ? doc.fonts[baseFontIdx].name : "";
+
+    function fontCss(idx){
+        var f = doc.fonts[idx];
+        if (!f || !f.name) return "";
+        var gen = GENERIC[f.family];
+        return "'" + f.name.replace(/'/g, "") + "'" + (gen ? ", " + gen : "");
+    }
+
+    function runsHTML(runs, heading){
+        var html = "", openLink = null, buf = "";
+        function closeLink(){
+            if (openLink !== null){ html += '<a href="' + escAttr(openLink) + '">' + buf + "</a>"; buf = ""; openLink = null; }
+        }
+        runs.forEach(function(r){
+            var c = r.chr, piece;
+            if (r.type === "br") piece = "<br>";
+            else if (r.type === "img"){
+                piece = '<img src="' + r.src + '" alt=""' + (r.w ? ' width="' + r.w + '"' : "") + ">";
+            } else {
+                piece = esc(r.text);
+                var style = [];
+                var fi = c.f < 0 ? doc.deff : c.f;
+                if (!heading && c.fs !== baseSize) style.push("font-size:" + (c.fs / 2) + "pt");
+                if (fi !== baseFontIdx){ var fc = fontCss(fi); if (fc) style.push("font-family:" + fc); }
+                if (c.cf > 0 && doc.colors[c.cf]) style.push("color:" + colorCss(doc.colors[c.cf]));
+                if (c.bg > 0 && doc.colors[c.bg]) style.push("background-color:" + colorCss(doc.colors[c.bg]));
+                if (style.length) piece = '<span style="' + escAttr(style.join(";")) + '">' + piece + "</span>";
+                if (c.sup) piece = "<sup>" + piece + "</sup>";
+                if (c.sub) piece = "<sub>" + piece + "</sub>";
+                if (c.s)   piece = "<s>" + piece + "</s>";
+                if (c.u && !c.link) piece = "<u>" + piece + "</u>";
+                if (c.i)   piece = "<i>" + piece + "</i>";
+                if (c.b && !heading) piece = "<b>" + piece + "</b>";
+            }
+            if (c.link !== openLink){ closeLink(); if (c.link) openLink = c.link; }
+            if (openLink !== null) buf += piece; else html += piece;
+        });
+        closeLink();
+        return html;
+    }
+    function blockStyle(pp, list){
+        var s = [];
+        if (pp.align) s.push("text-align:" + pp.align);
+        if (!list){
+            if (pp.li > 0) s.push("margin-left:" + Math.round(pp.li / 15) + "px");
+            if (pp.fi) s.push("text-indent:" + Math.round(pp.fi / 15) + "px");
+        }
+        return s.length ? ' style="' + s.join(";") + '"' : "";
+    }
+    function isBlank(p){
+        return !p.runs.some(function(r){ return r.type !== "text" || /\S/.test(r.text.replace(/\u00A0/g, "x")); });
+    }
+    function paraHTML(p){
+        var pp = p.pp || defaultPara();
+        if (pp.brdrb && isBlank(p)) return "<hr>";
+        var tag = (pp.outline >= 0 && pp.outline <= 5) ? "h" + (pp.outline + 1) : "p";
+        var inner = runsHTML(p.runs, tag !== "p");
+        return "<" + tag + blockStyle(pp) + ">" + (inner || "<br>") + "</" + tag + ">";
+    }
+    function isList(p){ return p.type !== "table" && (p.marker || p.listType || (p.pp && p.pp.ls > 0)); }
+    function listLevel(p){
+        if (p.pp.ilvl >= 0) return p.pp.ilvl;
+        return Math.max(0, Math.round(p.pp.li / 720) - 1);
+    }
+    function listKind(p){
+        if (p.listType) return p.listType;
+        return /^\s*([0-9]+|[a-zA-Z]|[ivxlcIVXLC]+)[.)]/.test(p.marker) ? "ol" : "ul";
+    }
+    function tableHTML(t){
+        var html = "<table><tbody>";
+        t.rows.forEach(function(r){
+            html += "<tr>";
+            r.forEach(function(c){
+                var cellHTML = c.map(function(p){ return runsHTML(p.runs, false); }).join("<br>");
+                html += "<td>" + (cellHTML || "<br>") + "</td>";
+            });
+            html += "</tr>";
+        });
+        return html + "</tbody></table>";
+    }
+
+    var out = "", bl = doc.blocks;
+    for (var i = 0; i < bl.length; i++){
+        var b = bl[i];
+        if (b.type === "table"){ out += tableHTML(b); continue; }
+        if (!isList(b)){ out += paraHTML(b); continue; }
+        // a run of list paragraphs → nested <ul>/<ol>
+        var stackL = [];                 // open list tags
+        var j = i;
+        for (; j < bl.length && isList(bl[j]); j++){
+            var p = bl[j], lvl = Math.min(listLevel(p), 8), kind = listKind(p);
+            while (stackL.length > lvl + 1){ out += "</li></" + stackL.pop() + ">"; }
+            if (stackL.length === lvl + 1 && stackL[lvl] !== kind){ out += "</li></" + stackL.pop() + ">"; }
+            if (stackL.length === lvl + 1) out += "</li>";
+            while (stackL.length < lvl + 1){ out += "<" + kind + ">"; stackL.push(kind); }
+            out += "<li" + blockStyle(p.pp, true) + ">" + (runsHTML(p.runs, false) || "<br>");
+        }
+        while (stackL.length) out += "</li></" + stackL.pop() + ">";
+        i = j - 1;
+    }
+    return { html: out || "<p><br></p>", baseSize: baseSize, baseFont: baseFont };
+}
+
+// ════════════════════════════════════════════════════════════════════════
+// Writer
+// ════════════════════════════════════════════════════════════════════════
+var HEAD_SCALE = { H1:2, H2:1.6, H3:1.3, H4:1.1, H5:1, H6:0.9 };
+var FONT_SIZE_ATTR = { 1:8, 2:10, 3:12, 4:14, 5:18, 6:24, 7:36 };
+var BLOCK_TAGS = /^(P|DIV|H[1-6]|UL|OL|LI|BLOCKQUOTE|PRE|TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|HR|SECTION|ARTICLE|FIGURE|HEADER|FOOTER|ASIDE|NAV|ADDRESS|DL|DT|DD)$/;
+var MONO = "Courier New";
+
+function parseColor(v){
+    if (!v) return null;
+    v = String(v).trim().toLowerCase();
+    if (v === "transparent" || v === "inherit" || v === "initial" || v === "currentcolor") return null;
+    var m = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
+    if (m){
+        var h = m[1];
+        if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
+        return { r:parseInt(h.substr(0,2),16), g:parseInt(h.substr(2,2),16), b:parseInt(h.substr(4,2),16) };
+    }
+    m = v.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)(?:[\s,/]+([\d.]+%?))?\s*\)$/);
+    if (m){
+        if (m[4] !== undefined && parseFloat(m[4]) === 0) return null;
+        return { r:+m[1], g:+m[2], b:+m[3] };
+    }
+    // named colours: let the browser resolve them
+    try {
+        var cv = document.createElement("canvas").getContext("2d");
+        cv.fillStyle = "#000001"; cv.fillStyle = v;
+        if (cv.fillStyle !== "#000001") return parseColor(cv.fillStyle);
+    } catch(e){}
+    return null;
+}
+function firstFamily(v){
+    if (!v) return "";
+    var f = String(v).split(",")[0].trim().replace(/^['"]|['"]$/g, "");
+    var g = f.toLowerCase();
+    if (g === "monospace") return MONO;
+    if (g === "serif") return "Times New Roman";
+    if (g === "sans-serif" || g === "system-ui" || g === "-apple-system" || g === "inherit") return "";
+    return f;
+}
+// CSS font-size → half-points (relative sizes resolve against the current size)
+function sizeHalfPts(v, cur){
+    if (!v) return 0;
+    var m = String(v).trim().match(/^([\d.]+)(pt|px|em|rem|%)?$/i);
+    if (!m) return 0;
+    var n = parseFloat(m[1]), unit = (m[2] || "px").toLowerCase();
+    var pt = unit === "pt" ? n : unit === "px" ? n * 0.75 : unit === "%" ? cur / 2 * n / 100 : cur / 2 * n;
+    return Math.max(2, Math.round(pt * 2));
+}
+
+function fromHTML(root, opts){
+    opts = opts || {};
+    var base = opts.baseSize || 24;
+    var fonts = [opts.baseFont || "Calibri"], fontIdx = {};
+    fontIdx[fonts[0].toLowerCase()] = 0;
+    var colors = [], colorIdx = {};
+    var out = [];
+
+    function fontNo(name){
+        if (!name) return 0;
+        var k = name.toLowerCase();
+        if (fontIdx[k] === undefined){ fontIdx[k] = fonts.length; fonts.push(name); }
+        return fontIdx[k];
+    }
+    function colorNo(c){
+        if (!c) return 0;
+        var k = c.r + "," + c.g + "," + c.b;
+        if (colorIdx[k] === undefined){ colors.push(c); colorIdx[k] = colors.length; }
+        return colorIdx[k];
+    }
+    function escText(s){
+        var r = "";
+        for (var i = 0; i < s.length; i++){
+            var c = s.charCodeAt(i), ch = s.charAt(i);
+            if (ch === "\\" || ch === "{" || ch === "}") r += "\\" + ch;
+            else if (ch === "\t") r += "\\tab ";
+            else if (ch === "\n") r += "\\line ";
+            else if (c === 0xA0) r += "\\~";
+            else if (c === 0x200B || c === 0xFEFF || c === 0x0D) continue;
+            else if (c < 0x20) continue;
+            else if (c < 0x80) r += ch;
+            else r += "\\u" + (c > 32767 ? c - 65536 : c) + "?";
+        }
+        return r;
+    }
+
+    // ── inline content → list of items ──
+    function fmtOf(el, f){
+        var n = copy(f), tag = el.tagName;
+        if (tag === "B" || tag === "STRONG") n.b = true;
+        else if (tag === "I" || tag === "EM" || tag === "CITE") n.i = true;
+        else if (tag === "U" || tag === "INS") n.u = true;
+        else if (tag === "S" || tag === "STRIKE" || tag === "DEL") n.s = true;
+        else if (tag === "SUP"){ n.sup = true; n.sub = false; }
+        else if (tag === "SUB"){ n.sub = true; n.sup = false; }
+        else if (tag === "CODE" || tag === "KBD" || tag === "TT" || tag === "SAMP") n.font = MONO;
+        else if (tag === "MARK") n.bg = n.bg || { r:255, g:255, b:0 };
+        if (tag === "FONT"){
+            var fc = parseColor(el.getAttribute("color")); if (fc) n.color = fc;
+            var ff = firstFamily(el.getAttribute("face")); if (ff) n.font = ff;
+            var fz = FONT_SIZE_ATTR[el.getAttribute("size")]; if (fz) n.size = fz * 2;
+        }
+        var s = el.style;
+        if (s){
+            if (s.fontWeight){ n.b = s.fontWeight === "bold" || s.fontWeight === "bolder" || parseInt(s.fontWeight, 10) >= 600; }
+            if (s.fontStyle){ n.i = s.fontStyle === "italic" || s.fontStyle === "oblique"; }
+            var td = s.textDecorationLine || s.textDecoration || "";
+            if (td.indexOf("underline") >= 0) n.u = true;
+            if (td.indexOf("line-through") >= 0) n.s = true;
+            if (td === "none"){ n.u = false; n.s = false; }
+            if (s.verticalAlign === "super"){ n.sup = true; n.sub = false; }
+            if (s.verticalAlign === "sub"){ n.sub = true; n.sup = false; }
+            if (s.fontSize){ var hp = sizeHalfPts(s.fontSize, n.size || base); if (hp) n.size = hp; }
+            if (s.fontFamily){ var fam = firstFamily(s.fontFamily); if (fam) n.font = fam; }
+            if (s.color){ var col = parseColor(s.color); if (col) n.color = col; }
+            if (s.backgroundColor){
+                var bg = parseColor(s.backgroundColor);
+                n.bg = bg;                                  // transparent clears it
+            }
+        }
+        return n;
+    }
+    function collect(nodes, f, items){
+        for (var i = 0; i < nodes.length; i++){
+            var nd = nodes[i];
+            if (nd.nodeType === 3){
+                var t = nd.nodeValue.replace(/\u200B/g, "");
+                if (t) items.push({ t:"text", text:t, f:f });
+                continue;
+            }
+            if (nd.nodeType !== 1) continue;
+            var tag = nd.tagName;
+            if (tag === "BR"){ items.push({ t:"br" }); continue; }
+            if (tag === "IMG"){ items.push({ t:"img", el:nd, f:f }); continue; }
+            if (tag === "SCRIPT" || tag === "STYLE" || tag === "SELECT") continue;
+            if (tag === "A" && nd.getAttribute("href")){
+                var sub = [];
+                collect(nd.childNodes, fmtOf(nd, f), sub);
+                items.push({ t:"link", url:nd.getAttribute("href"), items:sub });
+                continue;
+            }
+            if (BLOCK_TAGS.test(tag)){                      // stray block inside inline flow
+                collect(nd.childNodes, f, items);
+                items.push({ t:"br" });
+                continue;
+            }
+            collect(nd.childNodes, fmtOf(nd, f), items);
+        }
+    }
+    function fmtCodes(f, extra){
+        var c = "";
+        if (f.b) c += "\\b";
+        if (f.i) c += "\\i";
+        if (f.u) c += "\\ul";
+        if (f.s) c += "\\strike";
+        if (f.sup) c += "\\super";
+        if (f.sub) c += "\\sub";
+        if (f.font) c += "\\f" + fontNo(f.font);
+        if (f.size && f.size !== base) c += "\\fs" + f.size;
+        if (f.color) c += "\\cf" + colorNo(f.color);
+        if (f.bg) c += "\\highlight" + colorNo(f.bg);
+        return c + (extra || "");
+    }
+    function itemsRTF(items){
+        // drop the trailing placeholder <br> contenteditable leaves in a line
+        while (items.length && items[items.length - 1].t === "br") items.pop();
+        var r = "", i = 0;
+        while (i < items.length){
+            var it = items[i];
+            if (it.t === "br"){ r += "\\line "; i++; continue; }
+            if (it.t === "img"){ r += pictRTF(it.el); i++; continue; }
+            if (it.t === "link"){
+                r += "{\\field{\\*\\fldinst{HYPERLINK \"" + escText(it.url).replace(/"/g, "%22") + "\"}}{\\fldrslt{\\ul\\cf" +
+                     colorNo({ r:5, g:99, b:193 }) + " " + itemsRTF(it.items.slice()) + "}}}";
+                i++; continue;
+            }
+            // merge consecutive text items with the same formatting
+            var codes = fmtCodes(it.f), text = it.text;
+            i++;
+            while (i < items.length && items[i].t === "text" && fmtCodes(items[i].f) === codes){ text += items[i].text; i++; }
+            r += codes ? "{" + codes + " " + escText(text) + "}" : escText(text);
+        }
+        return r;
+    }
+    function pictRTF(img){
+        var src = img.getAttribute("src") || "";
+        var m = src.match(/^data:image\/(png|jpe?g);base64,(.*)$/i);
+        if (!m) return "";
+        var bin;
+        try { bin = atob(m[2]); } catch(e){ return ""; }
+        var hex = [];
+        for (var i = 0; i < bin.length; i++){
+            hex.push(("0" + bin.charCodeAt(i).toString(16)).slice(-2));
+            if (i % 64 === 63) hex.push("\r\n");
+        }
+        var natW = +img.getAttribute("data-natw") || +img.getAttribute("width") || 300;
+        var natH = +img.getAttribute("data-nath") || +img.getAttribute("height") || 150;
+        var dispW = +img.getAttribute("width") || parseFloat(img.style.width) || natW;
+        var dispH = +img.getAttribute("height") || parseFloat(img.style.height) || Math.round(natH * dispW / natW);
+        if (dispW > 624){ dispH = Math.round(dispH * 624 / dispW); dispW = 624; }
+        var type = /png/i.test(m[1]) ? "\\pngblip" : "\\jpegblip";
+        return "{\\pict" + type + "\\picw" + natW + "\\pich" + natH + "\\picwgoal" + Math.round(dispW * 15) +
+               "\\pichgoal" + Math.round(dispH * 15) + "\r\n" + hex.join("") + "}";
+    }
+
+    // ── blocks ──
+    function alignOf(el, inherited){
+        var a = (el.style && el.style.textAlign) || el.getAttribute("align") || "";
+        a = a.toLowerCase();
+        if (a === "center") return "\\qc";
+        if (a === "right" || a === "end") return "\\qr";
+        if (a === "justify") return "\\qj";
+        if (a === "left" || a === "start") return "";
+        return inherited || "";
+    }
+    function indentOf(el){
+        var ml = el.style ? el.style.marginLeft : "";
+        var m = ml && ml.match(/^([\d.]+)(px|pt)?$/);
+        return m ? Math.round(parseFloat(m[1]) * (m[2] === "pt" ? 20 : 15)) : 0;
+    }
+    function textIndentOf(el){
+        var ti = el.style ? el.style.textIndent : "";
+        var m = ti && ti.match(/^(-?[\d.]+)(px|pt)?$/);
+        return m ? Math.round(parseFloat(m[1]) * (m[2] === "pt" ? 20 : 15)) : 0;
+    }
+    function para(nodes, ctx, opt){
+        opt = opt || {};
+        var f = { b:false, i:false, u:false, s:false, sup:false, sub:false };
+        if (opt.font) f.font = opt.font;
+        if (ctx.bold) f.b = true;
+        if (opt.heading){ f.b = true; f.size = Math.round(base * HEAD_SCALE[opt.heading] / 2) * 2; }
+        var items = [];
+        collect(nodes, f, items);
+        var head = "\\pard" + (ctx.intbl ? "\\intbl" : "") + (opt.align || "");
+        var li = (ctx.indent || 0) + (opt.li || 0);
+        if (li) head += "\\li" + li;
+        if (opt.fi) head += "\\fi" + opt.fi;
+        if (opt.heading) head += "\\outlinelevel" + (parseInt(opt.heading.charAt(1), 10) - 1) + "\\sb240";
+        head += "\\sa" + (opt.sa === undefined ? 120 : opt.sa) + "\\plain\\f0\\fs" + base + " ";
+        out.push(head + (opt.prefix || "") + itemsRTF(items) + (opt.end || "\\par") + "\r\n");
+    }
+    function hasContent(nodes){
+        for (var i = 0; i < nodes.length; i++){
+            var n = nodes[i];
+            if (n.nodeType === 3 && n.nodeValue.replace(/[\u200B\s]/g, "") !== "") return true;
+            if (n.nodeType === 1) return true;
+        }
+        return false;
+    }
+    function container(el, ctx){
+        var buf = [];
+        function flushInline(){
+            if (buf.length && hasContent(buf)) para(buf, ctx, { align:ctx.align });
+            buf = [];
+        }
+        for (var i = 0; i < el.childNodes.length; i++){
+            var c = el.childNodes[i];
+            if (c.nodeType === 1 && BLOCK_TAGS.test(c.tagName)){ flushInline(); block(c, ctx); }
+            else buf.push(c);
+        }
+        flushInline();
+    }
+    function hasBlockChild(el){
+        for (var i = 0; i < el.children.length; i++) if (BLOCK_TAGS.test(el.children[i].tagName)) return true;
+        return false;
+    }
+    function block(el, ctx){
+        var tag = el.tagName, align = alignOf(el, ctx.align);
+        if (/^H[1-6]$/.test(tag)){ para(el.childNodes, ctx, { align:align, heading:tag }); return; }
+        if (tag === "HR"){ out.push("\\pard" + (ctx.intbl ? "\\intbl" : "") + "\\brdrb\\brdrs\\brdrw10\\brsp20\\sa120\\plain\\f0\\fs" + base + " \\par\r\n"); return; }
+        if (tag === "PRE"){
+            var lines = (el.textContent || "").replace(/\u200B/g, "").replace(/\n$/, "").split("\n");
+            lines.forEach(function(ln, k){
+                var tn = document.createTextNode(ln);
+                para([tn], ctx, { font:MONO, sa:(k === lines.length - 1 ? 120 : 0) });
+            });
+            return;
+        }
+        if (tag === "UL" || tag === "OL"){ list(el, ctx); return; }
+        if (tag === "BLOCKQUOTE"){
+            container(el, { indent:(ctx.indent || 0) + 720, align:align, intbl:ctx.intbl, level:ctx.level });
+            return;
+        }
+        if (tag === "TABLE"){ table(el, ctx); return; }
+        if (tag === "P" || tag === "DT" || tag === "DD" || !hasBlockChild(el)){
+            para(el.childNodes, ctx, { align:align, li:indentOf(el), fi:textIndentOf(el) });
+            return;
+        }
+        container(el, { indent:(ctx.indent || 0) + indentOf(el), align:align, intbl:ctx.intbl, level:ctx.level });
+    }
+    function list(el, ctx){
+        var ordered = el.tagName === "OL";
+        var level = (ctx.level === undefined ? -1 : ctx.level) + 1;
+        var num = parseInt(el.getAttribute("start"), 10) || 1;
+        var li = 720 * (level + 1);
+        for (var i = 0; i < el.children.length; i++){
+            var item = el.children[i];
+            if (item.tagName !== "LI"){ block(item, ctx); continue; }
+            var marker = ordered ? (num++) + "." : "\\u8226?";
+            var pn = ordered
+                ? "{\\*\\pn\\pnlvlbody\\pndec\\pnstart1\\pnindent360{\\pntxta.}}"
+                : "{\\*\\pn\\pnlvlblt\\pnf" + fontNo("Symbol") + "\\pnindent360{\\pntxtb\\'B7}}";
+            var prefix = "{\\listtext\\pard\\plain\\f0\\fs" + base + " " + marker + "\\tab}" + pn;
+            var sub = { indent:ctx.indent, align:ctx.align, intbl:ctx.intbl, level:level };
+            // the item's own text runs up to its first block child
+            var inline = [], rest = [];
+            for (var k = 0; k < item.childNodes.length; k++){
+                var c = item.childNodes[k];
+                if (rest.length || (c.nodeType === 1 && BLOCK_TAGS.test(c.tagName))) rest.push(c);
+                else inline.push(c);
+            }
+            if (!hasContent(inline) && rest.length && /^(P|DIV)$/.test(rest[0].tagName)){
+                inline = Array.prototype.slice.call(rest.shift().childNodes);
+            }
+            para(inline, ctx, { align:alignOf(item, ctx.align), li:li, fi:-360, sa:40, prefix:prefix });
+            rest.forEach(function(c){
+                if (c.tagName === "UL" || c.tagName === "OL") list(c, sub);
+                else block(c, { indent:(ctx.indent || 0) + li, align:ctx.align, intbl:ctx.intbl, level:level });
+            });
+        }
+    }
+    function table(el, ctx){
+        var rows = el.querySelectorAll("tr");
+        for (var r = 0; r < rows.length; r++){
+            var cells = [];
+            for (var c = 0; c < rows[r].children.length; c++){
+                if (/^T[DH]$/.test(rows[r].children[c].tagName)) cells.push(rows[r].children[c]);
+            }
+            if (!cells.length) continue;
+            var w = Math.floor(9000 / cells.length), def = "\\trowd\\trgaph108\\trleft0";
+            for (var k = 0; k < cells.length; k++){
+                def += "\\clbrdrt\\brdrs\\brdrw10\\clbrdrl\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10\\cellx" + (w * (k + 1));
+            }
+            out.push(def + "\r\n");
+            for (var k2 = 0; k2 < cells.length; k2++){
+                var cell = cells[k2], isHead = cell.tagName === "TH";
+                var start = out.length;
+                var cctx = { intbl:true, align:alignOf(cell, ""), bold:isHead };
+                if (hasBlockChild(cell)) container(cell, cctx);
+                else para(cell.childNodes, cctx, { align:cctx.align, sa:0 });
+                if (out.length === start) para([], cctx, { sa:0 });
+                // the cell's last paragraph ends with \cell instead of \par
+                out[out.length - 1] = out[out.length - 1].replace(/\\par\r\n$/, "\\cell\r\n");
+            }
+            out.push(def + "\\row\r\n");
+        }
+    }
+
+    container(root, { indent:0, align:"" });
+
+    var ft = "{\\fonttbl";
+    fonts.forEach(function(name, i){
+        var fam = name === MONO ? "\\fmodern" : name === "Symbol" ? "\\ftech" : "\\fnil";
+        var cs = name === "Symbol" ? 2 : (/[^\x00-\x7f]/.test(name) ? 1 : 0);
+        ft += "{\\f" + i + fam + "\\fcharset" + cs + " " + escText(name) + ";}";
+    });
+    ft += "}";
+    var ct = "{\\colortbl ;";
+    colors.forEach(function(c){ ct += "\\red" + c.r + "\\green" + c.g + "\\blue" + c.b + ";"; });
+    ct += "}";
+    return "{\\rtf1\\ansi\\ansicpg1252\\deff0\\nouicompat" + ft + "\r\n" + ct + "\r\n" +
+           "{\\*\\generator ArozOS Text;}\\viewkind4\\uc1\r\n" + out.join("") + "}\r\n";
+}
+
+// ════════════════════════════════════════════════════════════════════════
+// Image preparation (async)
+// ════════════════════════════════════════════════════════════════════════
+function blobToDataURL(blob){
+    return new Promise(function(res, rej){
+        var fr = new FileReader();
+        fr.onload = function(){ res(fr.result); };
+        fr.onerror = rej;
+        fr.readAsDataURL(blob);
+    });
+}
+function loadImage(src){
+    return new Promise(function(res, rej){
+        var im = new Image();
+        im.onload = function(){ res(im); };
+        im.onerror = function(){ rej(new Error("image load failed")); };
+        im.src = src;
+    });
+}
+function inlineOne(img){
+    var src = img.getAttribute("src") || "";
+    var p = /^data:image\/(png|jpe?g);/i.test(src)
+        ? Promise.resolve(src)
+        : fetch(src, { credentials:"same-origin" }).then(function(r){
+              if (!r.ok) throw new Error("HTTP " + r.status);
+              return r.blob();
+          }).then(function(b){
+              if (/^image\/(png|jpe?g)$/i.test(b.type)) return blobToDataURL(b);
+              // other formats (gif/webp/svg/bmp) are re-encoded as PNG
+              var url = URL.createObjectURL(b);
+              return loadImage(url).then(function(im){
+                  URL.revokeObjectURL(url);
+                  var cv = document.createElement("canvas");
+                  cv.width = im.naturalWidth || 300; cv.height = im.naturalHeight || 150;
+                  cv.getContext("2d").drawImage(im, 0, 0);
+                  return cv.toDataURL("image/png");
+              });
+          }).then(function(d){
+              // sniff the real format: a mislabelled blob still needs png/jpeg bytes
+              return d.replace(/^data:[^;,]*;/, function(){
+                  var head = atob(d.split(",")[1].substr(0, 8));
+                  return head.charCodeAt(0) === 0xFF ? "data:image/jpeg;" : "data:image/png;";
+              });
+          });
+    return p.then(function(d){
+        img.setAttribute("src", d);
+        return loadImage(d).then(function(im){
+            img.setAttribute("data-natw", im.naturalWidth);
+            img.setAttribute("data-nath", im.naturalHeight);
+        });
+    }).catch(function(){
+        img.parentNode && img.parentNode.removeChild(img);   // unreachable image: leave it out
+    });
+}
+function inlineImages(root){
+    var imgs = Array.prototype.slice.call(root.querySelectorAll("img"));
+    return Promise.all(imgs.map(inlineOne)).then(function(){ return root; });
+}
+
+window.TextRTF = { toHTML:toHTML, fromHTML:fromHTML, inlineImages:inlineImages, parse:parse };
+})();

+ 51 - 155
src/web/desktop.html

@@ -13,6 +13,7 @@
     <script type="text/javascript" src="./script/ao_module.js"></script>
     <script type="text/javascript" src="./SystemAO/desktop/script/jsCalendar/source/jsCalendar.js"></script>
     <script type="text/javascript" src="./script/applocale.js"></script>
+    <script type="text/javascript" src="./script/chunkupload.js"></script>
     <!-- Sentinel paths (%trashbin%, ...), shared with the File Manager -->
     <script type="text/javascript" src="./SystemAO/file_system/shared/specialpaths.js"></script>
     <!-- <script type="text/javascript" src="./script/html2canvas.min.js"></script> -->
@@ -1562,27 +1563,9 @@
         let uploadFileChunkSize = 1024 * 512; //512KB per chunk in low memory upload
         let largeFileCutoffSize = 8192 * 1024 * 1024; //Any file larger than this size is consider "large file", default to 8GB
         let postUploadModeCutoff = 25 * 1048576; //25MB, files smaller than this will upload using POST Mode
-        const CHUNK_TIMEOUT_MS = 30000; //30s to wait for server ack before retrying a chunk
-        const MAX_CHUNK_RETRIES = 3;    //Maximum per-chunk retries before marking the upload failed
-
-        //CRC32 lookup table and helpers (IEEE polynomial, matches Go's hash/crc32 package)
-        const crc32Table = (() => {
-            const t = new Uint32Array(256);
-            for (let i = 0; i < 256; i++) {
-                let c = i;
-                for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
-                t[i] = c;
-            }
-            return t;
-        })();
-        function crc32UpdateState(state, bytes) {
-            for (let i = 0; i < bytes.length; i++)
-                state = (state >>> 8) ^ crc32Table[(state ^ bytes[i]) & 0xFF];
-            return state;
-        }
-        function crc32Hex(bytes) {
-            return ((crc32UpdateState(0xFFFFFFFF, bytes) ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
-        }
+        let uploadWindowSize = 8 * 1024 * 1024; //Bytes of chunks kept in flight per upload connection (see script/chunkupload.js)
+        const CHUNK_TIMEOUT_MS = 30000; //30s without any server ack (once everything queued has been sent) before resending the oldest chunk
+        const MAX_CHUNK_RETRIES = 3;    //Maximum retries without any progress before marking the upload failed
 
         //Others
         var monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
@@ -7366,148 +7349,61 @@
                 }
                 
 
-                let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + filename + "&path=" + uploadDir + hugeFileMode);
-                let currentSendingIndex = 0;
-                let chunks = Math.ceil(file.size/uploadFileChunkSize);
-
-                // Per-chunk retry state
-                let chunkRetryCount = 0;
-                let chunkTimeoutTimer = null;
-
-                // Running CRC32 state across all chunks for full-file checksum
-                let runningCRC32State = 0xFFFFFFFF;
-                let chunkCRC32Committed = {};
-
-                async function sendChunk(id) {
-                    let offsetStart = id * uploadFileChunkSize;
-                    let offsetEnd   = id * uploadFileChunkSize + uploadFileChunkSize;
-                    let arrayBuffer;
-                    try {
-                        arrayBuffer = await file.slice(offsetStart, offsetEnd).arrayBuffer();
-                    } catch(e) {
-                        console.error("[Desktop Upload] Failed to read chunk " + id + ": " + e);
-                        return;
-                    }
-
-                    let bytes = new Uint8Array(arrayBuffer);
-
-                    // Update running full-file CRC32 only on first attempt for each chunk
-                    if (!chunkCRC32Committed[id]) {
-                        runningCRC32State = crc32UpdateState(runningCRC32State, bytes);
-                        chunkCRC32Committed[id] = true;
-                    }
-
-                    let chunkCRCHex = crc32Hex(bytes);
-
-                    // Send metadata frame then binary frame
-                    socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
-                    socket.send(arrayBuffer);
+                //Chunking, pipelining, checksums and retries live in
+                //script/chunkupload.js, shared with the File Manager
+                let uploadIcon = function(){
+                    return $("." + uploadingIconUUID + ".launchIcon");
+                };
+                let showUploadFailed = function(){
+                    uploadIcon().find(".progress").removeClass("primary").addClass("error");
+                    uploadIcon().find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
+                    setTimeout(function(){
+                        uploadIcon().fadeOut(3000, function() { $(this).remove(); });
+                    }, 2000);
+                };
 
-                    // Update desktop icon progress (cap at 95% to reserve room for merge)
-                    let progress = chunks <= 1 ? 50 : (id / (chunks - 1) * 95.0);
-                    if (progress > 95) progress = 95;
-                    if (uploadingIconUUID != undefined){
-                        $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", progress + "%");
-                        if (progress >= 95){
-                            $("." + uploadingIconUUID + ".launchIcon").find(".progress").addClass("indeterminate");
+                ChunkUpload.start({
+                    url: protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + filename + "&path=" + uploadDir + hugeFileMode,
+                    file: file,
+                    chunkSize: uploadFileChunkSize,
+                    windowSize: uploadWindowSize,
+                    ackTimeout: CHUNK_TIMEOUT_MS,
+                    maxRetries: MAX_CHUNK_RETRIES,
+                    onProgress: function(loaded, total){
+                        //Cap at 95% to reserve room for the server side merge
+                        if (uploadingIconUUID == undefined){
+                            return;
                         }
-                    }
-
-                    // Start ack timeout; retry chunk on expiry
-                    clearTimeout(chunkTimeoutTimer);
-                    chunkTimeoutTimer = setTimeout(function() {
-                        if (chunkRetryCount < MAX_CHUNK_RETRIES) {
-                            chunkRetryCount++;
-                            console.warn("[Desktop Upload] Chunk " + id + " ACK timeout – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
-                            sendChunk(id);
-                        } else {
-                            console.error("[Desktop Upload] Chunk " + id + " failed after max retries");
-                            socket.close();
+                        let progress = Math.min(95, loaded / total * 95.0);
+                        uploadIcon().find(".bar").css("width", progress + "%");
+                        if (progress >= 95){
+                            uploadIcon().find(".progress").addClass("indeterminate");
                         }
-                    }, CHUNK_TIMEOUT_MS);
-                }
-
-                //Start sending
-                socket.onopen = async function(e) {
-                    if (filesize < uploadFileChunkSize){
+                    },
+                    onMove: function(){
+                        // Server is moving file from tmp to destination
                         if (uploadingIconUUID != undefined){
-                            $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", "15%");
+                            uploadIcon().find(".bar").css("width", "100%");
                         }
-                    }
-                    await sendChunk(0);
-                    currentSendingIndex = 1;
-                };
-
-                socket.onmessage = async function(event) {
-                    var incomingValue = event.data;
-
-                    if (incomingValue == "next"){
-                        clearTimeout(chunkTimeoutTimer);
-                        chunkRetryCount = 0;
-
-                        if (currentSendingIndex >= chunks){
-                            // All chunks acknowledged – send done with full-file checksum
-                            let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
-                            socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
-                        }else{
-                            await sendChunk(currentSendingIndex);
-                            currentSendingIndex++;
+                    },
+                    onError: function(message, fromServer){
+                        if (fromServer){
+                            sendNotification("Upload Failed", message, "remove");
+                            showUploadFailed();
                         }
-
-                    }else if (incomingValue == "OK"){
-                        //Merge completed successfully – icon will be cleaned up by onclose
-                    }else{
-                        try{
-                            var resp = JSON.parse(incomingValue);
-                            if (resp.error !== undefined){
-                                sendNotification("Upload Failed", resp.error, "remove");
-                                clearTimeout(chunkTimeoutTimer);
-                                $("." + uploadingIconUUID + ".launchIcon").find(".progress").removeClass("primary").addClass("error");
-                                $("." + uploadingIconUUID + ".launchIcon").find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
-                                setTimeout(function(){
-                                    $("." + uploadingIconUUID + ".launchIcon").fadeOut(3000, function() { $(this).remove(); });
-                                }, 2000);
-                            } else if (resp.retryChunk !== undefined){
-                                // Server-requested chunk retry (CRC32 mismatch)
-                                clearTimeout(chunkTimeoutTimer);
-                                if (chunkRetryCount < MAX_CHUNK_RETRIES) {
-                                    chunkRetryCount++;
-                                    console.warn("[Desktop Upload] Server requested retry for chunk " + resp.retryChunk);
-                                    await sendChunk(resp.retryChunk);
-                                } else {
-                                    console.error("[Desktop Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
-                                    socket.close();
-                                }
-                            } else if (resp.move !== undefined){
-                                // Server is moving file from tmp to destination
-                                if (uploadingIconUUID != undefined){
-                                    $("." + uploadingIconUUID + ".launchIcon").find(".bar").css("width", "100%");
-                                }
-                            }
-                        }catch(ex){
-                            console.log(incomingValue);
-                            console.log(ex);
+                    },
+                    onClose: function(){
+                        //Merge completed or failed - either way the icon goes
+                        $("." + uploadingIconUUID).remove();
+                        if (callback != undefined){
+                            callback();
                         }
+                    },
+                    onSocketError: function(error){
+                        console.error("[Desktop Upload] WebSocket error:", error);
+                        showUploadFailed();
                     }
-                };
-
-                socket.onclose = function(event) {
-                    clearTimeout(chunkTimeoutTimer);
-                    $("." + uploadingIconUUID).remove();
-                    if (callback != undefined){
-                        callback();
-                    }
-                };
-
-                socket.onerror = function(error) {
-                    console.error("[Desktop Upload] WebSocket error:", error);
-                    clearTimeout(chunkTimeoutTimer);
-                    $("." + uploadingIconUUID + ".launchIcon").find(".progress").removeClass("primary").addClass("error");
-                    $("." + uploadingIconUUID + ".launchIcon").find(".launchIconText").text(applocale.getString("upload/message/failed", "Failed!"));
-                    setTimeout(function(){
-                        $("." + uploadingIconUUID + ".launchIcon").fadeOut(3000, function() { $(this).remove(); });
-                    }, 2000);
-                };
+                });
             }else{
                 let url = 'system/file_system/upload'
                 let uploadCurrentPath = "user:/Desktop/";

+ 485 - 0
src/web/script/chunkupload.js

@@ -0,0 +1,485 @@
+/*
+    chunkupload.js
+
+    Client side of the low-memory (WebSocket chunked) upload channel,
+    /system/file_system/lowmemUpload. Shared by the File Manager and the
+    desktop so both speak exactly the same protocol.
+
+    Protocol, per chunk: a text frame {"index": N, "checksum": "<crc32 hex>"}
+    followed by a binary frame with the chunk bytes. The server takes chunks
+    strictly in index order and answers each one it accepts with "next".
+    Anything else it silently drops: a copy of a chunk it already has, or a
+    chunk sent ahead of one that failed its CRC. On a CRC mismatch it asks for
+    {"retryChunk": N}. When every chunk is acknowledged the client sends
+    {"done": true, "totalChunks": N, "fileChecksum": "<crc32 hex>"} and the
+    server merges the chunks, reporting {"move": "..."} and finally "OK".
+
+    Pipelining: instead of waiting for "next" after every chunk, up to
+    windowSize bytes of chunks are kept in flight, so one connection is no
+    longer capped at one chunk per round trip. Because the server answers in
+    order, each "next" acknowledges the oldest chunk still in flight. A retry
+    request rewinds the sender to the chunk that failed (go-back-N); an
+    acknowledgement timeout resends just the oldest unacknowledged chunk. The
+    server drops whatever it receives out of order, so both are always safe.
+
+    Every chunk is read and checksummed exactly once per send. The whole-file
+    CRC32 is built from the chunk CRCs with crc32Combine instead of a second
+    pass over the bytes.
+*/
+
+(function (global) {
+    "use strict";
+
+    /*
+        CRC32 (IEEE, reflected, polynomial 0xEDB88320) - matches Go's
+        hash/crc32.ChecksumIEEE. Slicing-by-8 tables: CRC_TABLES[k] advances the
+        CRC by one byte followed by k zero bytes.
+    */
+    const CRC_POLY = 0xEDB88320;
+    const CRC_TABLES = (function () {
+        let tables = [];
+        for (let k = 0; k < 8; k++) {
+            tables.push(new Int32Array(256));
+        }
+        for (let i = 0; i < 256; i++) {
+            let c = i;
+            for (let j = 0; j < 8; j++) {
+                c = (c & 1) ? (CRC_POLY ^ (c >>> 1)) : (c >>> 1);
+            }
+            tables[0][i] = c;
+        }
+        for (let i = 0; i < 256; i++) {
+            let c = tables[0][i];
+            for (let k = 1; k < 8; k++) {
+                c = tables[0][c & 0xFF] ^ (c >>> 8);
+                tables[k][i] = c;
+            }
+        }
+        return tables;
+    })();
+
+    //CRC32 of a Uint8Array as an unsigned 32-bit number
+    function crc32(bytes) {
+        const t0 = CRC_TABLES[0], t1 = CRC_TABLES[1], t2 = CRC_TABLES[2], t3 = CRC_TABLES[3];
+        const t4 = CRC_TABLES[4], t5 = CRC_TABLES[5], t6 = CRC_TABLES[6], t7 = CRC_TABLES[7];
+        let crc = -1;
+        let i = 0;
+        const len = bytes.length;
+        const fast = len - (len % 8);
+        while (i < fast) {
+            const lo = crc ^ (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24));
+            crc = t7[lo & 0xFF] ^ t6[(lo >>> 8) & 0xFF] ^ t5[(lo >>> 16) & 0xFF] ^ t4[lo >>> 24] ^
+                  t3[bytes[i + 4]] ^ t2[bytes[i + 5]] ^ t1[bytes[i + 6]] ^ t0[bytes[i + 7]];
+            i += 8;
+        }
+        while (i < len) {
+            crc = t0[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
+            i++;
+        }
+        return (crc ^ -1) >>> 0;
+    }
+
+    /*
+        crc32Combine(crcA, crcB, lenB) returns the CRC32 of A followed by B,
+        given only the CRC of each part and the byte length of B. Port of
+        zlib's crc32_combine (multmodp / x2nmodp).
+    */
+    function multmodp(a, b) {
+        let m = 0x80000000;
+        let p = 0;
+        for (;;) {
+            if (a & m) {
+                p ^= b;
+                if ((a & (m - 1)) === 0) {
+                    break;
+                }
+            }
+            m = m / 2;
+            b = (b & 1) ? ((b >>> 1) ^ CRC_POLY) : (b >>> 1);
+        }
+        return p >>> 0;
+    }
+
+    //X2N_TABLE[k] = x^(2^k) modulo the CRC polynomial
+    const X2N_TABLE = (function () {
+        let table = new Array(32);
+        let p = 0x40000000; //x^1
+        table[0] = p;
+        for (let n = 1; n < 32; n++) {
+            p = multmodp(p, p);
+            table[n] = p;
+        }
+        return table;
+    })();
+
+    //x^(n * 2^k) modulo the CRC polynomial
+    function x2nmodp(n, k) {
+        let p = 0x80000000; //x^0 == 1
+        while (n > 0) {
+            if (n % 2 === 1) {
+                p = multmodp(X2N_TABLE[k & 31], p);
+            }
+            n = Math.floor(n / 2);
+            k++;
+        }
+        return p;
+    }
+
+    function crc32Combine(crcA, crcB, lenB) {
+        return (multmodp(x2nmodp(lenB, 3), crcA) ^ crcB) >>> 0;
+    }
+
+    function crc32ToHex(crc) {
+        return (crc >>> 0).toString(16).padStart(8, "0");
+    }
+
+    function noop() {}
+
+    /*
+        Start uploading file over a new WebSocket to url.
+
+        Options (all callbacks optional):
+          url, file         - required
+          chunkSize         - bytes per chunk (default 1MB)
+          windowSize        - bytes allowed in flight (default 8MB, at least one chunk)
+          ackTimeout        - ms without any acknowledgement before rewinding (default 30s)
+          maxRetries        - rewinds allowed without progress before failing (default 3)
+          pingInterval      - heartbeat interval while paused (default 20s)
+          onProgress(ackedBytes, totalBytes)
+          onProcessing()    - every chunk acknowledged, server is merging
+          onMove(status)    - merge progress text from the server
+          onDone()          - server reported "OK"
+          onError(message, fromServer) - the upload failed
+          onClose(event)    - the WebSocket closed, for any reason
+          onSocketError(error)
+
+        Returns a controller: {pause(), resume(), abort(), isPaused(), socket}
+    */
+    function start(opts) {
+        const file = opts.file;
+        const chunkSize = opts.chunkSize || 1024 * 1024;
+        const windowChunks = Math.max(1, Math.floor((opts.windowSize || 8 * 1024 * 1024) / chunkSize));
+        const ackTimeout = opts.ackTimeout || 30000;
+        const maxRetries = (opts.maxRetries === undefined) ? 3 : opts.maxRetries;
+        const pingInterval = opts.pingInterval || 20000;
+        const chunks = Math.ceil(file.size / chunkSize);
+
+        const onProgress = opts.onProgress || noop;
+        const onProcessing = opts.onProcessing || noop;
+        const onMove = opts.onMove || noop;
+        const onDone = opts.onDone || noop;
+        const onError = opts.onError || noop;
+        const onClose = opts.onClose || noop;
+        const onSocketError = opts.onSocketError || noop;
+
+        const socket = new WebSocket(opts.url);
+
+        let acked = 0;          //chunks [0, acked) are accepted by the server
+        let nextIndex = 0;      //next chunk to put on the wire
+        let generation = 0;     //bumped on every rewind, invalidates a send in progress
+        let pumping = false;
+        let retries = 0;        //rewinds since the last acknowledgement
+
+        //Whole-file CRC, folded from each chunk's CRC the first time it is sent
+        let fileCRC = 0;
+        let crcFolded = 0;
+
+        let paused = false;
+        let doneSent = false;
+        let failed = false;
+        let aborted = false;
+        let ackTimer = null;
+        let pingTimer = null;
+
+        //No more chunk traffic once the upload is finished one way or another
+        function halted() {
+            return doneSent || failed || aborted;
+        }
+
+        function send(data) {
+            try {
+                socket.send(data);
+            } catch (e) {}
+        }
+
+        function stopAckTimer() {
+            clearTimeout(ackTimer);
+            ackTimer = null;
+        }
+
+        function armAckTimer() {
+            stopAckTimer();
+            if (paused || halted() || acked >= nextIndex) {
+                return;
+            }
+            ackTimer = setTimeout(onAckTimeout, ackTimeout);
+        }
+
+        function onAckTimeout() {
+            ackTimer = null;
+            if (paused || halted()) {
+                return;
+            }
+            if (socket.bufferedAmount > 0) {
+                //Still handing bytes to the network - a slow link, not a lost
+                //chunk. Resending now would only queue duplicates behind them.
+                armAckTimer();
+                return;
+            }
+            if (retries >= maxRetries) {
+                fail("Upload stalled: no acknowledgement from server", false);
+                return;
+            }
+            retries++;
+            console.warn("[Upload] Chunk " + acked + " not acknowledged - resending it (" + retries + "/" + maxRetries + ")");
+            /*
+                Only the oldest chunk, not the whole window. On a live socket
+                nothing is actually lost - the server is just slow - so every
+                resent chunk is a duplicate it has to read and drop before it
+                can acknowledge anything new. Rewinding a full window here would
+                bury the next acknowledgement under it and time out again.
+            */
+            resendChunk(acked);
+            armAckTimer();
+        }
+
+        //Put one chunk on the wire again without moving the send position
+        async function resendChunk(id) {
+            let bytes;
+            try {
+                bytes = await readChunk(id);
+            } catch (e) {
+                console.error("[Upload] Failed to read chunk " + id + ": " + e);
+                fail("Failed to read file", false);
+                return;
+            }
+            if (halted() || id < acked || socket.readyState != WebSocket.OPEN) {
+                return;
+            }
+            sendChunk(id, bytes, crc32(new Uint8Array(bytes)));
+        }
+
+        function readChunk(id) {
+            const offsetStart = id * chunkSize;
+            return file.slice(offsetStart, Math.min(file.size, offsetStart + chunkSize)).arrayBuffer();
+        }
+
+        //The header and its payload always leave back to back, with no await
+        //between them, so two senders can never interleave a pair
+        function sendChunk(id, buffer, chunkCRC) {
+            send(JSON.stringify({index: id, checksum: crc32ToHex(chunkCRC)}));
+            send(buffer);
+        }
+
+        function startPing() {
+            stopPing();
+            pingTimer = setInterval(function () {
+                if (socket.readyState != WebSocket.OPEN) {
+                    stopPing();
+                    return;
+                }
+                send(JSON.stringify({ping: true}));
+            }, pingInterval);
+        }
+
+        function stopPing() {
+            if (pingTimer != null) {
+                clearInterval(pingTimer);
+                pingTimer = null;
+            }
+        }
+
+        function fail(message, fromServer) {
+            if (failed || aborted) {
+                return;
+            }
+            failed = true;
+            stopAckTimer();
+            stopPing();
+            onError(message, fromServer);
+            if (!fromServer) {
+                try { socket.close(); } catch (e) {}
+            }
+        }
+
+        //Go back to chunk index and send everything from there again
+        function rewind(index) {
+            generation++;
+            nextIndex = Math.max(acked, Math.min(index, nextIndex));
+            armAckTimer();
+            pump();
+        }
+
+        //Fill the window. Only one pump runs at a time so chunks leave in order.
+        async function pump() {
+            if (pumping) {
+                return;
+            }
+            pumping = true;
+            try {
+                while (!paused && !halted() && socket.readyState == WebSocket.OPEN &&
+                        nextIndex < chunks && nextIndex - acked < windowChunks) {
+                    const id = nextIndex;
+                    const gen = generation;
+
+                    let buffer;
+                    try {
+                        buffer = await readChunk(id);
+                    } catch (e) {
+                        console.error("[Upload] Failed to read chunk " + id + ": " + e);
+                        fail("Failed to read file", false);
+                        return;
+                    }
+                    if (gen !== generation) {
+                        //Rewound while reading, re-evaluate from the top
+                        continue;
+                    }
+                    if (paused || halted() || socket.readyState != WebSocket.OPEN) {
+                        break;
+                    }
+
+                    const chunkCRC = crc32(new Uint8Array(buffer));
+                    if (id === crcFolded) {
+                        //First time this chunk is sent (a resend never runs ahead of it)
+                        fileCRC = crc32Combine(fileCRC, chunkCRC, buffer.byteLength);
+                        crcFolded++;
+                    }
+
+                    sendChunk(id, buffer, chunkCRC);
+                    nextIndex = id + 1;
+                    if (ackTimer == null) {
+                        armAckTimer();
+                    }
+                }
+
+                if (!paused && !halted() && acked >= chunks && socket.readyState == WebSocket.OPEN) {
+                    doneSent = true;
+                    stopAckTimer();
+                    send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: crc32ToHex(fileCRC)}));
+                    onProcessing();
+                }
+            } finally {
+                pumping = false;
+            }
+        }
+
+        socket.onopen = function () {
+            if (paused) {
+                //Paused before the socket connected - tell the server now, or
+                //it reaps the silent connection as idle
+                send(JSON.stringify({pause: true}));
+                startPing();
+                return;
+            }
+            pump();
+        };
+
+        socket.onmessage = function (event) {
+            const incoming = event.data;
+
+            if (incoming == "next") {
+                if (acked < chunks) {
+                    acked++;
+                    if (nextIndex < acked) {
+                        //The original copy got through after a rewind
+                        nextIndex = acked;
+                    }
+                }
+                retries = 0;
+                onProgress(Math.min(file.size, acked * chunkSize), file.size);
+                armAckTimer();
+                pump();
+                return;
+            }
+
+            if (incoming == "OK") {
+                onDone();
+                return;
+            }
+
+            let resp;
+            try {
+                resp = JSON.parse(incoming);
+            } catch (e) {
+                console.log("[Upload] Unexpected message: ", incoming);
+                return;
+            }
+
+            if (resp.pong !== undefined) {
+                //Heartbeat reply while paused
+                return;
+            } else if (resp.error !== undefined) {
+                fail(resp.error, true);
+            } else if (resp.retryChunk !== undefined) {
+                if (halted()) {
+                    return;
+                }
+                if (retries >= maxRetries) {
+                    console.error("[Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
+                    fail("Chunk checksum mismatch after " + maxRetries + " retries", false);
+                    return;
+                }
+                retries++;
+                console.warn("[Upload] Server requested retry for chunk " + resp.retryChunk + " (CRC32 mismatch) - retry " + retries + "/" + maxRetries);
+                rewind(resp.retryChunk);
+            } else if (resp.move !== undefined) {
+                onMove(resp.move);
+            }
+        };
+
+        socket.onclose = function (event) {
+            stopAckTimer();
+            stopPing();
+            onClose(event);
+        };
+
+        socket.onerror = function (error) {
+            onSocketError(error);
+        };
+
+        return {
+            socket: socket,
+            isPaused: function () {
+                return paused;
+            },
+            pause: function () {
+                if (paused || halted()) {
+                    return;
+                }
+                paused = true;
+                //Chunks already in flight still get acknowledged; only the ack
+                //clock stops, or a legitimately idle upload would "time out"
+                stopAckTimer();
+                if (socket.readyState == WebSocket.OPEN) {
+                    send(JSON.stringify({pause: true}));
+                    startPing();
+                }
+            },
+            resume: function () {
+                if (!paused) {
+                    return;
+                }
+                paused = false;
+                stopPing();
+                if (socket.readyState == WebSocket.OPEN) {
+                    send(JSON.stringify({resume: true}));
+                    armAckTimer();
+                    pump();
+                }
+            },
+            abort: function () {
+                aborted = true;
+                stopAckTimer();
+                stopPing();
+                try { socket.close(); } catch (e) {}
+            }
+        };
+    }
+
+    global.ChunkUpload = {
+        start: start,
+        crc32: crc32,
+        crc32Combine: crc32Combine,
+        crc32ToHex: crc32ToHex
+    };
+})(window);