Lagrange [release]

Saving inline media content to Downloads

73faede0ce49a06e117971e75bfe8fb30ec937ca
diff --git a/res/about/version.gmi b/res/about/version.gmi
index 48b4cbd1..c2c47fe4 100644
--- a/res/about/version.gmi
+++ b/res/about/version.gmi
@@ -8,6 +8,7 @@
 
 ## 0.10
 * Added option to load inline images when pressing Space or ↓ for a more focused reading experience — just keep tapping a single key to proceed. If an image link is visible, it will be loaded instead of scrolling. This option is disabled by default.
+* Added context menu item to save inline images to Downloads.
 * Added an option to use a proxy server for Gemini requests.
 * Added a new keyboard link navigation mode focusing on the home row keys. The default keybinding for this is "F".
 * Added a keybinding to activate keyboard link modifier mode. The keyboard link keys are active while the modifier is held down. The default is ${ALT}.
diff --git a/src/app.c b/src/app.c
index b53666c8..1440bab9 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1083,6 +1083,7 @@ iBool handleCommand_App(const char *cmd) {
         }
         setInitialScroll_DocumentWidget(doc, argfLabel_Command(cmd, "scroll"));
         setRedirectCount_DocumentWidget(doc, redirectCount);
+        setFlags_Widget(findWidget_App("document.progress"), hidden_WidgetFlag, iTrue);
         setUrlFromCache_DocumentWidget(doc, url, isHistory);
         /* Optionally, jump to a text in the document. This will only work if the document
            is already available, e.g., it's from "about:" or restored from cache. */
diff --git a/src/gmdocument.c b/src/gmdocument.c
index 80ddfc9d..c8fc9869 100644
--- a/src/gmdocument.c
+++ b/src/gmdocument.c
@@ -1377,7 +1377,7 @@ iBool isMediaLink_GmDocument(const iGmDocument *d, iGmLinkId linkId) {
     const iString *dstUrl = absoluteUrl_String(&d->url, linkUrl_GmDocument(d, linkId));
     const iRangecc scheme = urlScheme_String(dstUrl);
     if (equalCase_Rangecc(scheme, "gemini") || equalCase_Rangecc(scheme, "gopher") ||
-        willUseProxy_App(scheme)) {
+        equalCase_Rangecc(scheme, "file") || willUseProxy_App(scheme)) {
         return (linkFlags_GmDocument(d, linkId) &
                 (imageFileExtension_GmLinkFlag | audioFileExtension_GmLinkFlag)) != 0;
     }
diff --git a/src/ui/documentwidget.c b/src/ui/documentwidget.c
index 3cf564ac..a10aa409 100644
--- a/src/ui/documentwidget.c
+++ b/src/ui/documentwidget.c
@@ -1264,6 +1264,78 @@ static iBool fetchNextUnfetchedImage_DocumentWidget_(iDocumentWidget *d) {
     return iFalse;
 }
 
+static void saveToDownloads_(const iString *url, const iString *mime, const iBlock *content) {
+    /* Figure out a file name from the URL. */
+    iUrl parts;
+    init_Url(&parts, url);
+    while (startsWith_Rangecc(parts.path, "/")) {
+        parts.path.start++;
+    }
+    while (endsWith_Rangecc(parts.path, "/")) {
+        parts.path.end--;
+    }
+    iString *name = collectNewCStr_String("pagecontent");
+    if (isEmpty_Range(&parts.path)) {
+        if (!isEmpty_Range(&parts.host)) {
+            setRange_String(name, parts.host);
+            replace_Block(&name->chars, '.', '_');
+        }
+    }
+    else {
+        iRangecc fn = { parts.path.start + lastIndexOfCStr_Rangecc(parts.path, "/") + 1,
+                        parts.path.end };
+        if (!isEmpty_Range(&fn)) {
+            setRange_String(name, fn);
+        }
+    }
+    if (startsWith_String(name, "~")) {
+        /* This would be interpreted as a reference to a home directory. */
+        remove_Block(&name->chars, 0, 1);
+    }
+    iString *savePath = concat_Path(downloadDir_App(), name);
+    if (lastIndexOfCStr_String(savePath, ".") == iInvalidPos) {
+        /* No extension specified in URL. */
+        if (startsWith_String(mime, "text/gemini")) {
+            appendCStr_String(savePath, ".gmi");
+        }
+        else if (startsWith_String(mime, "text/")) {
+            appendCStr_String(savePath, ".txt");
+        }
+        else if (startsWith_String(mime, "image/")) {
+            appendCStr_String(savePath, cstr_String(mime) + 6);
+        }
+    }
+    if (fileExists_FileInfo(savePath)) {
+        /* Make it unique. */
+        iDate now;
+        initCurrent_Date(&now);
+        size_t insPos = lastIndexOfCStr_String(savePath, ".");
+        if (insPos == iInvalidPos) {
+            insPos = size_String(savePath);
+        }
+        const iString *date = collect_String(format_Date(&now, "_%Y-%m-%d_%H%M%S"));
+        insertData_Block(&savePath->chars, insPos, cstr_String(date), size_String(date));
+    }
+    /* Write the file. */ {
+        iFile *f = new_File(savePath);
+        if (open_File(f, writeOnly_FileMode)) {
+            write_File(f, content);
+            const size_t size   = size_Block(content);
+            const iBool  isMega = size >= 1000000;
+            makeMessage_Widget(uiHeading_ColorEscape "FILE SAVED",
+                               format_CStr("%s\nSize: %.3f %s", cstr_String(path_File(f)),
+                                           isMega ? size / 1.0e6f : (size / 1.0e3f),
+                                           isMega ? "MB" : "KB"));
+        }
+        else {
+            makeMessage_Widget(uiTextCaution_ColorEscape "ERROR SAVING FILE",
+                               strerror(errno));
+        }
+        iRelease(f);
+    }
+    delete_String(savePath);
+}
+
 static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd) {
     iWidget *w = as_Widget(d);
     if (equal_Command(cmd, "window.resized") || equal_Command(cmd, "font.changed")) {
@@ -1471,82 +1543,21 @@ static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd)
             return iTrue;
         }
     }
+    else if (equalWidget_Command(cmd, w, "document.media.save")) {
+        const iGmLinkId      linkId = argLabel_Command(cmd, "link");
+        const iMediaRequest *media  = findMediaRequest_DocumentWidget_(d, linkId);
+        if (media) {
+            saveToDownloads_(url_GmRequest(media->req), meta_GmRequest(media->req),
+                             body_GmRequest(media->req));
+        }
+    }
     else if (equal_Command(cmd, "document.save") && document_App() == d) {
         if (d->request) {
             makeMessage_Widget(uiTextCaution_ColorEscape "PAGE INCOMPLETE",
                                "The page contents are still being downloaded.");
         }
         else if (!isEmpty_Block(&d->sourceContent)) {
-            /* Figure out a file name from the URL. */
-            /* TODO: Make this a utility function. */
-            iUrl parts;
-            init_Url(&parts, d->mod.url);
-            while (startsWith_Rangecc(parts.path, "/")) {
-                parts.path.start++;
-            }
-            while (endsWith_Rangecc(parts.path, "/")) {
-                parts.path.end--;
-            }
-            iString *name = collectNewCStr_String("pagecontent");
-            if (isEmpty_Range(&parts.path)) {
-                if (!isEmpty_Range(&parts.host)) {
-                    setRange_String(name, parts.host);
-                    replace_Block(&name->chars, '.', '_');
-                }
-            }
-            else {
-                iRangecc fn = { parts.path.start + lastIndexOfCStr_Rangecc(parts.path, "/") + 1,
-                                parts.path.end };
-                if (!isEmpty_Range(&fn)) {
-                    setRange_String(name, fn);
-                }
-            }
-            if (startsWith_String(name, "~")) {
-                /* This would be interpreted as a reference to a home directory. */
-                remove_Block(&name->chars, 0, 1);
-            }
-            iString *savePath = concat_Path(downloadDir_App(), name);
-            if (lastIndexOfCStr_String(savePath, ".") == iInvalidPos) {
-                /* No extension specified in URL. */
-                if (startsWith_String(&d->sourceMime, "text/gemini")) {
-                    appendCStr_String(savePath, ".gmi");
-                }
-                else if (startsWith_String(&d->sourceMime, "text/")) {
-                    appendCStr_String(savePath, ".txt");
-                }
-                else if (startsWith_String(&d->sourceMime, "image/")) {
-                    appendCStr_String(savePath, cstr_String(&d->sourceMime) + 6);
-                }
-            }
-            if (fileExists_FileInfo(savePath)) {
-                /* Make it unique. */
-                iDate now;
-                initCurrent_Date(&now);
-                size_t insPos = lastIndexOfCStr_String(savePath, ".");
-                if (insPos == iInvalidPos) {
-                    insPos = size_String(savePath);
-                }
-                const iString *date = collect_String(format_Date(&now, "_%Y-%m-%d_%H%M%S"));
-                insertData_Block(&savePath->chars, insPos, cstr_String(date), size_String(date));
-            }
-            /* Write the file. */ {
-                iFile *f = new_File(savePath);
-                if (open_File(f, writeOnly_FileMode)) {
-                    write_File(f, &d->sourceContent);
-                    const size_t size   = size_Block(&d->sourceContent);
-                    const iBool  isMega = size >= 1000000;
-                    makeMessage_Widget(uiHeading_ColorEscape "PAGE SAVED",
-                                       format_CStr("%s\nSize: %.3f %s", cstr_String(path_File(f)),
-                                                   isMega ? size / 1.0e6f : (size / 1.0e3f),
-                                                   isMega ? "MB" : "KB"));
-                }
-                else {
-                    makeMessage_Widget(uiTextCaution_ColorEscape "ERROR SAVING PAGE",
-                                       strerror(errno));
-                }
-                iRelease(f);
-            }
-            delete_String(savePath);
+            saveToDownloads_(d->mod.url, &d->sourceMime, &d->sourceContent);
         }
         return iTrue;
     }
@@ -1568,6 +1579,12 @@ static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd)
         return iTrue;
     }
     else if (equal_Command(cmd, "navigate.back") && document_App() == d) {
+        if (d->request) {
+            postCommandf_App(
+                "document.request.cancelled doc:%p url:%s", d, cstr_String(d->mod.url));
+            iReleasePtr(&d->request);
+            updateFetchProgress_DocumentWidget_(d);
+        }
         goBack_History(d->mod.history);
         return iTrue;
     }
@@ -2093,6 +2110,17 @@ static iBool processEvent_DocumentWidget_(iDocumentWidget *d, const SDL_Event *e
                                     (iMenuItem[]){ { "---", 0, 0, NULL },
                                                    { "Copy Link", 0, 0, "document.copylink" } },
                                     2);
+                    iMediaRequest *mediaReq;
+                    if ((mediaReq = findMediaRequest_DocumentWidget_(d, d->contextLink->linkId)) != NULL) {
+                        if (isFinished_GmRequest(mediaReq->req)) {
+                            pushBack_Array(&items,
+                                           &(iMenuItem){ "Save to Downloads",
+                                                         0,
+                                                         0,
+                                                         format_CStr("document.media.save link:%u",
+                                                                     d->contextLink->linkId) });
+                        }
+                    }
                 }
                 else {
                     if (!isEmpty_Range(&d->selectMark)) {
@@ -2778,7 +2806,6 @@ static void draw_DocumentWidget_(const iDocumentWidget *d) {
                 }
             }
             endTarget_Paint(&ctx.paint);
-//            fflush(stdout);
         }
         validate_VisBuf(visBuf);
         clear_PtrSet(d->invalidRuns);