Let the lightbox save the picture it is showing
A picture in a message was cached and looked at, and that was as far as it went: keeping one meant knowing where the media cache is and what the hash in front of the filename means. So the lightbox grows a Save beside its Back. Where a save lands is the host's business, so it is a seam op named for the result — `save-to-downloads!` takes a path and the name to file it under and answers with where it actually went. jolt reads XDG_DOWNLOAD_DIR, then ~/Downloads, then home, and copies with `cp` because the seam's `slurp` is a string and a PNG through a string is a broken PNG. Flutter asks `Platform.isAndroid` rather than assuming, the way `write-private-file!` already has to: the shared Download store on a phone, XDG on the desktop, the app's own storage where neither is reachable. Neither side writes over a file that is there — a taken name becomes `picture-1.png` — and the answer is shown rather than assumed, because a button that only stops looking pressed has not said where anything went. The name comes off the URL, not the cache: the hash in the cache name keeps two `image.png` apart in one directory of ours and means nothing in yours. The screen is under common/, so libcosmic gets the button too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
e5adf58 parent: ed06c1a modified
common/frq/cells.cljc +7 -1 | @@ -137,6 +137,12 @@ | ||
| 137 | 137 | ;; so this is a screen of its own rather than a layer over the chat. |
| 138 | 138 | (defonce lightbox (atom nil)) ; {:path :url} |
| 139 | 139 | |
| 140 | +;; What the lightbox's Save has to say for itself: the path a picture landed | |
| 141 | +;; at, or `:failed`, or nil before anyone asked. Saving is a file appearing | |
| 142 | +;; somewhere off screen, so the one thing the reader needs back is where — a | |
| 143 | +;; button that only stops looking pressed has told them nothing. | |
| 144 | +(defonce saved-to (atom nil)) | |
| 145 | + | |
| 140 | 146 | ;; The room the reader was in when a line in the overview took them somewhere |
| 141 | 147 | ;; else, or nil. The strip is the one place in the app that moves you without |
| 142 | 148 | ;; you having asked to leave where you were — everything else is a room you |
| @@ -266,5 +272,5 @@ | ||
| 266 | 272 | join-input search broker-token login-url draft editing replying-to |
| 267 | 273 | attachment jump-tick show-users? hide-chat-list? overview? at-present? |
| 268 | 274 | emoji-group emoji-search highlight jump-to lightbox overview-return |
| 269 | - reacting window-height window-width reaction-hover hide-join-part? | |
| 275 | + saved-to reacting window-height window-width reaction-hover hide-join-part? | |
| 270 | 276 | image-picker profile-viewing profile-hovering profile-tick]) |
| @@ -137,6 +137,12 @@ | |||
| 137 | ;; so this is a screen of its own rather than a layer over the chat. | 137 | ;; so this is a screen of its own rather than a layer over the chat. |
| 138 | (defonce lightbox (atom nil)) ; {:path :url} | 138 | (defonce lightbox (atom nil)) ; {:path :url} |
| 139 | 139 | ||
| 140 | +;; What the lightbox's Save has to say for itself: the path a picture landed | ||
| 141 | +;; at, or `:failed`, or nil before anyone asked. Saving is a file appearing | ||
| 142 | +;; somewhere off screen, so the one thing the reader needs back is where — a | ||
| 143 | +;; button that only stops looking pressed has told them nothing. | ||
| 144 | +(defonce saved-to (atom nil)) | ||
| 145 | + | ||
| 140 | ;; The room the reader was in when a line in the overview took them somewhere | 146 | ;; The room the reader was in when a line in the overview took them somewhere |
| 141 | ;; else, or nil. The strip is the one place in the app that moves you without | 147 | ;; else, or nil. The strip is the one place in the app that moves you without |
| 142 | ;; you having asked to leave where you were — everything else is a room you | 148 | ;; you having asked to leave where you were — everything else is a room you |
| @@ -266,5 +272,5 @@ | |||
| 266 | join-input search broker-token login-url draft editing replying-to | 272 | join-input search broker-token login-url draft editing replying-to |
| 267 | attachment jump-tick show-users? hide-chat-list? overview? at-present? | 273 | attachment jump-tick show-users? hide-chat-list? overview? at-present? |
| 268 | emoji-group emoji-search highlight jump-to lightbox overview-return | 274 | emoji-group emoji-search highlight jump-to lightbox overview-return |
| 269 | - reacting window-height window-width reaction-hover hide-join-part? | 275 | + saved-to reacting window-height window-width reaction-hover hide-join-part? |
| 270 | image-picker profile-viewing profile-hovering profile-tick]) | 276 | image-picker profile-viewing profile-hovering profile-tick]) |
modified
common/frq/io.cljc +18 -0 | @@ -79,6 +79,24 @@ | ||
| 79 | 79 | [path s] |
| 80 | 80 | (call :spit [path s])) |
| 81 | 81 | |
| 82 | +(defn save-to-downloads! | |
| 83 | + "Put a copy of the file at `path` where this reader keeps the things they | |
| 84 | + save, under `filename`, and answer with where it landed — or nil. | |
| 85 | + | |
| 86 | + Named for the result, like the rest of the seam, because \"where downloads | |
| 87 | + go\" is a different question on each of the three targets this has to answer | |
| 88 | + on: an XDG directory on a Linux desktop, the shared Download store on | |
| 89 | + Android, the app's own storage where neither of those is there. The caller | |
| 90 | + has a picture out of the media cache and wants it kept somewhere a file | |
| 91 | + manager will find it; which directory that is is the host's business. | |
| 92 | + | |
| 93 | + The name is a request rather than a promise: a file already there is not | |
| 94 | + overwritten, so what comes back may be `picture-1.png` for a `picture.png` | |
| 95 | + that was asked for. Callers show the answer, which is the only honest way to | |
| 96 | + say where a thing went." | |
| 97 | + [path filename] | |
| 98 | + (call :save-to-downloads! [path filename])) | |
| 99 | + | |
| 82 | 100 | (defn write-private-file! |
| 83 | 101 | "`spit`, for a file nobody else may read — mode 600 where that means |
| 84 | 102 | something. The broker token goes through this and nothing else does." |
| @@ -79,6 +79,24 @@ | |||
| 79 | [path s] | 79 | [path s] |
| 80 | (call :spit [path s])) | 80 | (call :spit [path s])) |
| 81 | 81 | ||
| 82 | +(defn save-to-downloads! | ||
| 83 | + "Put a copy of the file at `path` where this reader keeps the things they | ||
| 84 | + save, under `filename`, and answer with where it landed — or nil. | ||
| 85 | + | ||
| 86 | + Named for the result, like the rest of the seam, because \"where downloads | ||
| 87 | + go\" is a different question on each of the three targets this has to answer | ||
| 88 | + on: an XDG directory on a Linux desktop, the shared Download store on | ||
| 89 | + Android, the app's own storage where neither of those is there. The caller | ||
| 90 | + has a picture out of the media cache and wants it kept somewhere a file | ||
| 91 | + manager will find it; which directory that is is the host's business. | ||
| 92 | + | ||
| 93 | + The name is a request rather than a promise: a file already there is not | ||
| 94 | + overwritten, so what comes back may be `picture-1.png` for a `picture.png` | ||
| 95 | + that was asked for. Callers show the answer, which is the only honest way to | ||
| 96 | + say where a thing went." | ||
| 97 | + [path filename] | ||
| 98 | + (call :save-to-downloads! [path filename])) | ||
| 99 | + | ||
| 82 | (defn write-private-file! | 100 | (defn write-private-file! |
| 83 | "`spit`, for a file nobody else may read — mode 600 where that means | 101 | "`spit`, for a file nobody else may read — mode 600 where that means |
| 84 | something. The broker token goes through this and nothing else does." | 102 | something. The broker token goes through this and nothing else does." |
modified
common/frq/screens/app.cljc +35 -3 | @@ -14,6 +14,7 @@ | ||
| 14 | 14 | (:require [clojure.string :as str] |
| 15 | 15 | [frq.actions :as actions] |
| 16 | 16 | [frq.cells :as cells] |
| 17 | + [frq.io :as io] | |
| 17 | 18 | [frq.metrics :refer [terminal?]] |
| 18 | 19 | [frq.rooms :as rooms] |
| 19 | 20 | [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog |
| @@ -83,6 +84,32 @@ | ||
| 83 | 84 | ;; row — so the panel is only ever on screen if the message list is given a |
| 84 | 85 | ;; width that stops short of it. |
| 85 | 86 | |
| 87 | +(defn- close-lightbox! | |
| 88 | + "Shut the lightbox, and take any note of a save with it: where the last | |
| 89 | + picture went is not news about the next one." | |
| 90 | + [] | |
| 91 | + (reset! cells/saved-to nil) | |
| 92 | + (reset! cells/lightbox nil)) | |
| 93 | + | |
| 94 | +(defn- save-name | |
| 95 | + "What to file a saved picture under: the last segment of the URL it came | |
| 96 | + from, which is the name the person who posted it gave it, and a plain one | |
| 97 | + where the URL has nothing usable on the end. | |
| 98 | + | |
| 99 | + Not the cache name — that carries the hash `frq.media.core` puts in front to | |
| 100 | + keep two `image.png` apart, which is a fact about our cache directory and | |
| 101 | + means nothing in a downloads folder." | |
| 102 | + [url] | |
| 103 | + (let [tail (-> (str url) (str/split #"[?#]") first (str/split #"/") last)] | |
| 104 | + (if (re-find #"^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$" (str tail)) tail "picture.png"))) | |
| 105 | + | |
| 106 | +(defn- save-picture! | |
| 107 | + "Copy the picture being looked at to where this host keeps saved files, and | |
| 108 | + say where it went. The host decides which directory that is; see | |
| 109 | + `frq.io/save-to-downloads!`." | |
| 110 | + [path url] | |
| 111 | + (reset! cells/saved-to (or (io/save-to-downloads! path (save-name url)) :failed))) | |
| 112 | + | |
| 86 | 113 | (defn lightbox-screen |
| 87 | 114 | "One picture, as big as the window will paint it. |
| 88 | 115 | |
| @@ -95,11 +122,16 @@ | ||
| 95 | 122 | gesture that opened it — but a way out you have to guess at is not one, and |
| 96 | 123 | the row costs the picture a line." |
| 97 | 124 | [] |
| 98 | - (let [{:keys [path]} @cells/lightbox] | |
| 125 | + (let [{:keys [path url]} @cells/lightbox | |
| 126 | + saved @cells/saved-to] | |
| 99 | 127 | [:vbox {:spacing 4 :margin 4} |
| 100 | 128 | [:hbox {:spacing 8} |
| 101 | - [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]] | |
| 102 | - [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) | |
| 129 | + [:button {:label "← Back" :on-click #(close-lightbox!)}] | |
| 130 | + [:button {:label "Save" :on-click #(save-picture! path url)}] | |
| 131 | + (cond | |
| 132 | + (= :failed saved) [:dim-label {:label "Could not save that picture."}] | |
| 133 | + (string? saved) [:dim-label {:label (str "Saved to " saved)}])] | |
| 134 | + [:image {:src path :fit true :on-click #(close-lightbox!)}]])) | |
| 103 | 135 | |
| 104 | 136 | (defn- profile-dialog |
| 105 | 137 | "Who someone is, as a dialog: centred over the window, with what you were |
| @@ -14,6 +14,7 @@ | |||
| 14 | (:require [clojure.string :as str] | 14 | (:require [clojure.string :as str] |
| 15 | [frq.actions :as actions] | 15 | [frq.actions :as actions] |
| 16 | [frq.cells :as cells] | 16 | [frq.cells :as cells] |
| 17 | + [frq.io :as io] | ||
| 17 | [frq.metrics :refer [terminal?]] | 18 | [frq.metrics :refer [terminal?]] |
| 18 | [frq.rooms :as rooms] | 19 | [frq.rooms :as rooms] |
| 19 | [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog | 20 | [frq.screens.chat :refer [chat-screen emoji-picker reactor-dialog |
| @@ -83,6 +84,32 @@ | |||
| 83 | ;; row — so the panel is only ever on screen if the message list is given a | 84 | ;; row — so the panel is only ever on screen if the message list is given a |
| 84 | ;; width that stops short of it. | 85 | ;; width that stops short of it. |
| 85 | 86 | ||
| 87 | +(defn- close-lightbox! | ||
| 88 | + "Shut the lightbox, and take any note of a save with it: where the last | ||
| 89 | + picture went is not news about the next one." | ||
| 90 | + [] | ||
| 91 | + (reset! cells/saved-to nil) | ||
| 92 | + (reset! cells/lightbox nil)) | ||
| 93 | + | ||
| 94 | +(defn- save-name | ||
| 95 | + "What to file a saved picture under: the last segment of the URL it came | ||
| 96 | + from, which is the name the person who posted it gave it, and a plain one | ||
| 97 | + where the URL has nothing usable on the end. | ||
| 98 | + | ||
| 99 | + Not the cache name — that carries the hash `frq.media.core` puts in front to | ||
| 100 | + keep two `image.png` apart, which is a fact about our cache directory and | ||
| 101 | + means nothing in a downloads folder." | ||
| 102 | + [url] | ||
| 103 | + (let [tail (-> (str url) (str/split #"[?#]") first (str/split #"/") last)] | ||
| 104 | + (if (re-find #"^[A-Za-z0-9._-]+\.[A-Za-z0-9]+$" (str tail)) tail "picture.png"))) | ||
| 105 | + | ||
| 106 | +(defn- save-picture! | ||
| 107 | + "Copy the picture being looked at to where this host keeps saved files, and | ||
| 108 | + say where it went. The host decides which directory that is; see | ||
| 109 | + `frq.io/save-to-downloads!`." | ||
| 110 | + [path url] | ||
| 111 | + (reset! cells/saved-to (or (io/save-to-downloads! path (save-name url)) :failed))) | ||
| 112 | + | ||
| 86 | (defn lightbox-screen | 113 | (defn lightbox-screen |
| 87 | "One picture, as big as the window will paint it. | 114 | "One picture, as big as the window will paint it. |
| 88 | 115 | ||
| @@ -95,11 +122,16 @@ | |||
| 95 | gesture that opened it — but a way out you have to guess at is not one, and | 122 | gesture that opened it — but a way out you have to guess at is not one, and |
| 96 | the row costs the picture a line." | 123 | the row costs the picture a line." |
| 97 | [] | 124 | [] |
| 98 | - (let [{:keys [path]} @cells/lightbox] | 125 | + (let [{:keys [path url]} @cells/lightbox |
| 126 | + saved @cells/saved-to] | ||
| 99 | [:vbox {:spacing 4 :margin 4} | 127 | [:vbox {:spacing 4 :margin 4} |
| 100 | [:hbox {:spacing 8} | 128 | [:hbox {:spacing 8} |
| 101 | - [:button {:label "← Back" :on-click #(reset! cells/lightbox nil)}]] | 129 | + [:button {:label "← Back" :on-click #(close-lightbox!)}] |
| 102 | - [:image {:src path :fit true :on-click #(reset! cells/lightbox nil)}]])) | 130 | + [:button {:label "Save" :on-click #(save-picture! path url)}] |
| 131 | + (cond | ||
| 132 | + (= :failed saved) [:dim-label {:label "Could not save that picture."}] | ||
| 133 | + (string? saved) [:dim-label {:label (str "Saved to " saved)}])] | ||
| 134 | + [:image {:src path :fit true :on-click #(close-lightbox!)}]])) | ||
| 103 | 135 | ||
| 104 | (defn- profile-dialog | 136 | (defn- profile-dialog |
| 105 | "Who someone is, as a dialog: centred over the window, with what you were | 137 | "Who someone is, as a dialog: centred over the window, with what you were |
modified
flutter/src/frq/io/dart.cljd +57 -0 | @@ -67,6 +67,62 @@ | ||
| 67 | 67 | true) |
| 68 | 68 | (catch Exception _ false)))) |
| 69 | 69 | |
| 70 | +(defn- dir-at | |
| 71 | + "`p` if there is a directory there, else nil." | |
| 72 | + [p] | |
| 73 | + (when (and (seq (str p)) (.existsSync (io/Directory. p))) p)) | |
| 74 | + | |
| 75 | +(defn- downloads-dir | |
| 76 | + "Where a save lands, asked rather than assumed — this file compiles for a | |
| 77 | + Linux desktop and for Android, and they do not agree. | |
| 78 | + | |
| 79 | + The desktop has XDG: `XDG_DOWNLOAD_DIR` where the user-dirs file names one, | |
| 80 | + otherwise `~/Downloads`, created if that is all that is missing. Android has | |
| 81 | + the shared `Download` store, which is writable without a permission on the | |
| 82 | + devices that still let an app see it at all, and where it is not, the app's | |
| 83 | + own storage is the honest answer — it is somewhere, and the caller shows | |
| 84 | + where." | |
| 85 | + [fallback] | |
| 86 | + (if (.-isAndroid io/Platform) | |
| 87 | + (or (dir-at "/storage/emulated/0/Download") | |
| 88 | + (dir-at "/sdcard/Download") | |
| 89 | + fallback) | |
| 90 | + (let [env (.-environment io/Platform) | |
| 91 | + xdg (get env "XDG_DOWNLOAD_DIR") | |
| 92 | + home (get env "HOME") | |
| 93 | + dir (cond (seq (str xdg)) (str xdg) | |
| 94 | + (seq (str home)) (str home "/Downloads") | |
| 95 | + :else nil)] | |
| 96 | + (when dir | |
| 97 | + (when-not (.existsSync (io/Directory. dir)) | |
| 98 | + (try (.createSync (io/Directory. dir) .recursive true) (catch Exception _ nil)))) | |
| 99 | + (or (dir-at dir) (dir-at (get env "HOME")) fallback)))) | |
| 100 | + | |
| 101 | +(defn- free-path | |
| 102 | + "`dir/name`, or the same with `-1`, `-2`… before the extension until it | |
| 103 | + names a file that is not there. A save never writes over one." | |
| 104 | + [dir name] | |
| 105 | + (let [m (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name) | |
| 106 | + stem (or (second m) name) | |
| 107 | + ext (or (nth m 2 nil) "")] | |
| 108 | + (loop [n 0] | |
| 109 | + (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)] | |
| 110 | + (cond | |
| 111 | + (not (.existsSync (io/File. p))) p | |
| 112 | + (> n 99) nil | |
| 113 | + :else (recur (inc n))))))) | |
| 114 | + | |
| 115 | +(defn- save-to-downloads! | |
| 116 | + "`copySync` and not a read-then-write: what is copied is a PNG, and the | |
| 117 | + seam's `spit` takes a string." | |
| 118 | + [fallback path filename] | |
| 119 | + (try | |
| 120 | + (when-let [dir (downloads-dir fallback)] | |
| 121 | + (when-let [dest (free-path dir (or filename "picture.png"))] | |
| 122 | + (.copySync (io/File. path) dest) | |
| 123 | + (when (.existsSync (io/File. dest)) dest))) | |
| 124 | + (catch Exception _ nil))) | |
| 125 | + | |
| 70 | 126 | (defn- local-offset-seconds |
| 71 | 127 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. |
| 72 | 128 | The offset is per-instant, which is what the caller wants." |
| @@ -115,6 +171,7 @@ | ||
| 115 | 171 | :slurp slurp* |
| 116 | 172 | :spit spit* |
| 117 | 173 | :write-private-file! write-private-file! |
| 174 | + :save-to-downloads! (fn [p f] (save-to-downloads! dir p f)) | |
| 118 | 175 | :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s)))) |
| 119 | 176 | :utf8-string utf8-string |
| 120 | 177 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) |
| @@ -67,6 +67,62 @@ | |||
| 67 | true) | 67 | true) |
| 68 | (catch Exception _ false)))) | 68 | (catch Exception _ false)))) |
| 69 | 69 | ||
| 70 | +(defn- dir-at | ||
| 71 | + "`p` if there is a directory there, else nil." | ||
| 72 | + [p] | ||
| 73 | + (when (and (seq (str p)) (.existsSync (io/Directory. p))) p)) | ||
| 74 | + | ||
| 75 | +(defn- downloads-dir | ||
| 76 | + "Where a save lands, asked rather than assumed — this file compiles for a | ||
| 77 | + Linux desktop and for Android, and they do not agree. | ||
| 78 | + | ||
| 79 | + The desktop has XDG: `XDG_DOWNLOAD_DIR` where the user-dirs file names one, | ||
| 80 | + otherwise `~/Downloads`, created if that is all that is missing. Android has | ||
| 81 | + the shared `Download` store, which is writable without a permission on the | ||
| 82 | + devices that still let an app see it at all, and where it is not, the app's | ||
| 83 | + own storage is the honest answer — it is somewhere, and the caller shows | ||
| 84 | + where." | ||
| 85 | + [fallback] | ||
| 86 | + (if (.-isAndroid io/Platform) | ||
| 87 | + (or (dir-at "/storage/emulated/0/Download") | ||
| 88 | + (dir-at "/sdcard/Download") | ||
| 89 | + fallback) | ||
| 90 | + (let [env (.-environment io/Platform) | ||
| 91 | + xdg (get env "XDG_DOWNLOAD_DIR") | ||
| 92 | + home (get env "HOME") | ||
| 93 | + dir (cond (seq (str xdg)) (str xdg) | ||
| 94 | + (seq (str home)) (str home "/Downloads") | ||
| 95 | + :else nil)] | ||
| 96 | + (when dir | ||
| 97 | + (when-not (.existsSync (io/Directory. dir)) | ||
| 98 | + (try (.createSync (io/Directory. dir) .recursive true) (catch Exception _ nil)))) | ||
| 99 | + (or (dir-at dir) (dir-at (get env "HOME")) fallback)))) | ||
| 100 | + | ||
| 101 | +(defn- free-path | ||
| 102 | + "`dir/name`, or the same with `-1`, `-2`… before the extension until it | ||
| 103 | + names a file that is not there. A save never writes over one." | ||
| 104 | + [dir name] | ||
| 105 | + (let [m (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name) | ||
| 106 | + stem (or (second m) name) | ||
| 107 | + ext (or (nth m 2 nil) "")] | ||
| 108 | + (loop [n 0] | ||
| 109 | + (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)] | ||
| 110 | + (cond | ||
| 111 | + (not (.existsSync (io/File. p))) p | ||
| 112 | + (> n 99) nil | ||
| 113 | + :else (recur (inc n))))))) | ||
| 114 | + | ||
| 115 | +(defn- save-to-downloads! | ||
| 116 | + "`copySync` and not a read-then-write: what is copied is a PNG, and the | ||
| 117 | + seam's `spit` takes a string." | ||
| 118 | + [fallback path filename] | ||
| 119 | + (try | ||
| 120 | + (when-let [dir (downloads-dir fallback)] | ||
| 121 | + (when-let [dest (free-path dir (or filename "picture.png"))] | ||
| 122 | + (.copySync (io/File. path) dest) | ||
| 123 | + (when (.existsSync (io/File. dest)) dest))) | ||
| 124 | + (catch Exception _ nil))) | ||
| 125 | + | ||
| 70 | (defn- local-offset-seconds | 126 | (defn- local-offset-seconds |
| 71 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. | 127 | "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop. |
| 72 | The offset is per-instant, which is what the caller wants." | 128 | The offset is per-instant, which is what the caller wants." |
| @@ -115,6 +171,7 @@ | |||
| 115 | :slurp slurp* | 171 | :slurp slurp* |
| 116 | :spit spit* | 172 | :spit spit* |
| 117 | :write-private-file! write-private-file! | 173 | :write-private-file! write-private-file! |
| 174 | + :save-to-downloads! (fn [p f] (save-to-downloads! dir p f)) | ||
| 118 | :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s)))) | 175 | :utf8-bytes (fn [s] (vec (.encode conv/utf8 (str s)))) |
| 119 | :utf8-string utf8-string | 176 | :utf8-string utf8-string |
| 120 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) | 177 | :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now)))) |
modified
src/frq/io/jolt.clj +39 -0 | @@ -52,6 +52,44 @@ | ||
| 52 | 52 | (defn- spit* [path s] |
| 53 | 53 | (try (spit path s) true (catch Exception _ false))) |
| 54 | 54 | |
| 55 | +(defn- downloads-dir | |
| 56 | + "Where this desktop keeps saved files: XDG's answer if the user-dirs file | |
| 57 | + has one, otherwise ~/Downloads, otherwise home. Created if it is only | |
| 58 | + missing — a reader with no ~/Downloads still means \"save it\"." | |
| 59 | + [] | |
| 60 | + (let [home (host/getenv "HOME") | |
| 61 | + xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v)) | |
| 62 | + dir (or xdg (when (seq home) (str home "/Downloads")) home)] | |
| 63 | + (when (seq dir) | |
| 64 | + (when-not (host/directory? dir) (host/mkdirs! dir)) | |
| 65 | + (when (host/directory? dir) dir)))) | |
| 66 | + | |
| 67 | +(defn- free-path | |
| 68 | + "`dir/name`, or the same with `-1`, `-2`… before the extension until it | |
| 69 | + names nothing. Nothing saved is ever written over." | |
| 70 | + [dir name] | |
| 71 | + (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name) | |
| 72 | + stem (or stem name) | |
| 73 | + ext (or ext "")] | |
| 74 | + (loop [n 0] | |
| 75 | + (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)] | |
| 76 | + (cond | |
| 77 | + (not (host/file-exists? p)) p | |
| 78 | + (> n 99) nil | |
| 79 | + :else (recur (inc n))))))) | |
| 80 | + | |
| 81 | +(defn- save-to-downloads! | |
| 82 | + "`cp` and not a read-then-write: what is being copied is a picture, the | |
| 83 | + seam's `slurp` is a string, and bytes through a string is how a PNG comes | |
| 84 | + out the other side broken." | |
| 85 | + [path filename] | |
| 86 | + (try | |
| 87 | + (when-let [dir (downloads-dir)] | |
| 88 | + (when-let [dest (free-path dir (or filename "picture.png"))] | |
| 89 | + (host/sh (str "cp '" path "' '" dest "'")) | |
| 90 | + (when (host/file-exists? dest) dest))) | |
| 91 | + (catch Exception _ nil))) | |
| 92 | + | |
| 55 | 93 | (defn- write-private-file! |
| 56 | 94 | "Created before it is written, so the token is never on disk world-readable |
| 57 | 95 | even for an instant." |
| @@ -75,6 +113,7 @@ | ||
| 75 | 113 | :slurp slurp* |
| 76 | 114 | :spit spit* |
| 77 | 115 | :write-private-file! write-private-file! |
| 116 | + :save-to-downloads! save-to-downloads! | |
| 78 | 117 | :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s)))) |
| 79 | 118 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) |
| 80 | 119 | :wall-nanos host/wall-nanos |
| @@ -52,6 +52,44 @@ | |||
| 52 | (defn- spit* [path s] | 52 | (defn- spit* [path s] |
| 53 | (try (spit path s) true (catch Exception _ false))) | 53 | (try (spit path s) true (catch Exception _ false))) |
| 54 | 54 | ||
| 55 | +(defn- downloads-dir | ||
| 56 | + "Where this desktop keeps saved files: XDG's answer if the user-dirs file | ||
| 57 | + has one, otherwise ~/Downloads, otherwise home. Created if it is only | ||
| 58 | + missing — a reader with no ~/Downloads still means \"save it\"." | ||
| 59 | + [] | ||
| 60 | + (let [home (host/getenv "HOME") | ||
| 61 | + xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v)) | ||
| 62 | + dir (or xdg (when (seq home) (str home "/Downloads")) home)] | ||
| 63 | + (when (seq dir) | ||
| 64 | + (when-not (host/directory? dir) (host/mkdirs! dir)) | ||
| 65 | + (when (host/directory? dir) dir)))) | ||
| 66 | + | ||
| 67 | +(defn- free-path | ||
| 68 | + "`dir/name`, or the same with `-1`, `-2`… before the extension until it | ||
| 69 | + names nothing. Nothing saved is ever written over." | ||
| 70 | + [dir name] | ||
| 71 | + (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name) | ||
| 72 | + stem (or stem name) | ||
| 73 | + ext (or ext "")] | ||
| 74 | + (loop [n 0] | ||
| 75 | + (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)] | ||
| 76 | + (cond | ||
| 77 | + (not (host/file-exists? p)) p | ||
| 78 | + (> n 99) nil | ||
| 79 | + :else (recur (inc n))))))) | ||
| 80 | + | ||
| 81 | +(defn- save-to-downloads! | ||
| 82 | + "`cp` and not a read-then-write: what is being copied is a picture, the | ||
| 83 | + seam's `slurp` is a string, and bytes through a string is how a PNG comes | ||
| 84 | + out the other side broken." | ||
| 85 | + [path filename] | ||
| 86 | + (try | ||
| 87 | + (when-let [dir (downloads-dir)] | ||
| 88 | + (when-let [dest (free-path dir (or filename "picture.png"))] | ||
| 89 | + (host/sh (str "cp '" path "' '" dest "'")) | ||
| 90 | + (when (host/file-exists? dest) dest))) | ||
| 91 | + (catch Exception _ nil))) | ||
| 92 | + | ||
| 55 | (defn- write-private-file! | 93 | (defn- write-private-file! |
| 56 | "Created before it is written, so the token is never on disk world-readable | 94 | "Created before it is written, so the token is never on disk world-readable |
| 57 | even for an instant." | 95 | even for an instant." |
| @@ -75,6 +113,7 @@ | |||
| 75 | :slurp slurp* | 113 | :slurp slurp* |
| 76 | :spit spit* | 114 | :spit spit* |
| 77 | :write-private-file! write-private-file! | 115 | :write-private-file! write-private-file! |
| 116 | + :save-to-downloads! save-to-downloads! | ||
| 78 | :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s)))) | 117 | :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s)))) |
| 79 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) | 118 | :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs)))) |
| 80 | :wall-nanos host/wall-nanos | 119 | :wall-nanos host/wall-nanos |