| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- (ns frontend.fs.capacitor-fs
- "Implementation of fs protocol for mobile"
- (:require ["@capacitor/filesystem" :refer [Encoding Filesystem Directory]]
- [cljs-bean.core :as bean]
- [clojure.string :as string]
- [goog.string :as gstring]
- [frontend.config :as config]
- [frontend.db :as db]
- [frontend.encrypt :as encrypt]
- [frontend.fs.protocol :as protocol]
- [frontend.mobile.util :as mobile-util]
- [frontend.state :as state]
- [frontend.util :as util]
- [lambdaisland.glogi :as log]
- [promesa.core :as p]
- [rum.core :as rum]))
- (when (mobile-util/native-ios?)
- (defn ios-ensure-documents!
- []
- (.ensureDocuments mobile-util/ios-file-container)))
- (when (mobile-util/native-android?)
- (defn- android-check-permission []
- (p/let [permission (.checkPermissions Filesystem)
- permission (-> permission
- bean/->clj
- :publicStorage)]
- (when-not (= permission "granted")
- (p/do!
- (.requestPermissions Filesystem))))))
- (defn- <write-file-with-utf8
- [path content]
- (when-not (string/blank? path)
- (-> (p/chain (.writeFile Filesystem (clj->js {:path path
- :data content
- :encoding (.-UTF8 Encoding)
- :recursive true}))
- #(js->clj % :keywordize-keys true))
- (p/catch (fn [error]
- (js/console.error "writeFile Error: " path ": " error)
- nil)))))
- (defn- <read-file-with-utf8
- [path]
- (when-not (string/blank? path)
- (-> (p/chain (.readFile Filesystem (clj->js {:path path
- :encoding (.-UTF8 Encoding)}))
- #(js->clj % :keywordize-keys true)
- #(get % :data nil))
- (p/catch (fn [error]
- (js/console.error "readFile Error: " path ": " error)
- nil)))))
- (defn- <readdir [path]
- (-> (p/chain (.readdir Filesystem (clj->js {:path path}))
- #(js->clj % :keywordize-keys true)
- :files)
- (p/catch (fn [error]
- (js/console.error "readdir Error: " path ": " error)
- nil))))
- (defn- <stat [path]
- (-> (p/chain (.stat Filesystem (clj->js {:path path}))
- #(js->clj % :keywordize-keys true))
- (p/catch (fn [error]
- (js/console.error "stat Error: " path ": " error)
- nil))))
- (defn readdir
- "readdir recursively"
- [path]
- (p/let [result (p/loop [result []
- dirs [path]]
- (if (empty? dirs)
- result
- (p/let [d (first dirs)
- files (<readdir d)
- files (->> files
- (remove (fn [{:keys [name type]}]
- (or (string/starts-with? name ".")
- (and (= type "directory")
- (or (= name "bak")
- (= name "version-files")))))))
- files-dir (->> files
- (filterv #(= (:type %) "directory"))
- (mapv :uri))
- files-result
- (p/all
- (->> files
- (filter #(= (:type %) "file"))
- (filter
- (fn [{:keys [uri]}]
- (some #(string/ends-with? uri %)
- [".md" ".markdown" ".org" ".edn" ".css"])))
- (mapv
- (fn [{:keys [uri] :as file-info}]
- (p/chain (<read-file-with-utf8 uri)
- #(assoc file-info :content %))))))]
- (p/recur (concat result files-result)
- (concat (rest dirs) files-dir)))))]
- (js->clj result :keywordize-keys true)))
- (defn- contents-matched?
- [disk-content db-content]
- (when (and (string? disk-content) (string? db-content))
- (if (encrypt/encrypted-db? (state/get-current-repo))
- (p/let [decrypted-content (encrypt/decrypt disk-content)]
- (= (string/trim decrypted-content) (string/trim db-content)))
- (p/resolved (= (string/trim disk-content) (string/trim db-content))))))
- (def backup-dir "logseq/bak")
- (def version-file-dir "logseq/version-files/local")
- (defn- get-backup-dir
- [repo-dir path bak-dir ext]
- (let [relative-path (-> path
- (string/replace (re-pattern (str "^" (gstring/regExpEscape repo-dir)))
- "")
- (string/replace (re-pattern (str "(?i)" (gstring/regExpEscape (str "." ext)) "$"))
- ""))]
- (util/safe-path-join repo-dir (str bak-dir "/" relative-path))))
- (defn- truncate-old-versioned-files!
- "reserve the latest 6 version files"
- [dir]
- (->
- (p/let [files (readdir dir)
- files (js->clj files :keywordize-keys true)
- old-versioned-files (drop 6 (reverse (sort-by :mtime files)))]
- (mapv (fn [file]
- (.deleteFile Filesystem (clj->js {:path (:uri file)})))
- old-versioned-files))
- (p/catch (fn [_]))))
- ;; TODO: move this to FS protocol
- (defn backup-file
- "backup CONTENT under DIR :backup-dir or :version-file-dir
- :backup-dir = `backup-dir`
- :version-file-dir = `version-file-dir`"
- [repo dir path content]
- {:pre [(contains? #{:backup-dir :version-file-dir} dir)]}
- (let [repo-dir (config/get-local-dir repo)
- ext (util/get-file-ext path)
- dir (case dir
- :backup-dir (get-backup-dir repo-dir path backup-dir ext)
- :version-file-dir (get-backup-dir repo-dir path version-file-dir ext))
- new-path (util/safe-path-join dir (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) "." ext))]
- (<write-file-with-utf8 new-path content)
- (truncate-old-versioned-files! dir)))
- (defn backup-file-handle-changed!
- [repo-dir file-path content]
- (let [divider-schema "://"
- file-schema (string/split file-path divider-schema)
- file-schema (if (> (count file-schema) 1) (first file-schema) "")
- dir-schema? (and (string? repo-dir)
- (string/includes? repo-dir divider-schema))
- repo-dir (if-not dir-schema?
- (str file-schema divider-schema repo-dir) repo-dir)
- backup-root (util/safe-path-join repo-dir backup-dir)
- backup-dir-parent (util/node-path.dirname file-path)
- backup-dir-parent (string/replace backup-dir-parent repo-dir "")
- backup-dir-name (util/node-path.name file-path)
- file-extname (.extname util/node-path file-path)
- file-root (util/safe-path-join backup-root backup-dir-parent backup-dir-name)
- file-path (util/safe-path-join file-root
- (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) file-extname))]
- (<write-file-with-utf8 file-path content)
- (truncate-old-versioned-files! file-root)))
- (defn- write-file-impl!
- [_this repo _dir path content {:keys [ok-handler error-handler old-content skip-compare?]} stat]
- (if (or (string/blank? repo) skip-compare?)
- (p/catch
- (p/let [result (<write-file-with-utf8 path content)]
- (when ok-handler
- (ok-handler repo path result)))
- (fn [error]
- (if error-handler
- (error-handler error)
- (log/error :write-file-failed error))))
- ;; Compare with disk content and backup if not equal
- (p/let [disk-content (<read-file-with-utf8 path)
- disk-content (or disk-content "")
- repo-dir (config/get-local-dir repo)
- ext (util/get-file-ext path)
- db-content (or old-content (db/get-file repo path) "")
- contents-matched? (contents-matched? disk-content db-content)]
- (cond
- (and
- (not= stat :not-found) ; file on the disk was deleted
- (not contents-matched?)
- (not (contains? #{"excalidraw" "edn" "css"} ext))
- (not (string/includes? path "/.recycle/")))
- (p/let [disk-content (encrypt/decrypt disk-content)]
- (state/pub-event! [:file/not-matched-from-disk path disk-content content]))
- :else
- (->
- (p/let [result (<write-file-with-utf8 path content)
- mtime (-> (js->clj stat :keywordize-keys true)
- :mtime)]
- (when-not contents-matched?
- (backup-file repo-dir :backup-dir path disk-content))
- (db/set-file-last-modified-at! repo path mtime)
- (p/let [content (if (encrypt/encrypted-db? (state/get-current-repo))
- (encrypt/decrypt content)
- content)]
- (db/set-file-content! repo path content))
- (when ok-handler
- (ok-handler repo path result))
- result)
- (p/catch (fn [error]
- (if error-handler
- (error-handler error)
- (log/error :write-file-failed error)))))))))
- (defn normalize-file-protocol-path [dir path]
- (let [dir (some-> dir (string/replace #"/+$" ""))
- dir (if (and (not-empty dir) (string/starts-with? dir "/"))
- (do
- (js/console.trace "WARN: detect absolute path, use URL instead")
- (str "file://" (js/encodeURI dir)))
- dir)
- path (some-> path (string/replace #"^/+" ""))
- safe-encode-url #(let [encoded-chars?
- (and (string? %) (boolean (re-find #"(?i)%[0-9a-f]{2}" %)))]
- (cond
- (not encoded-chars?)
- (js/encodeURI %)
- :else
- (js/encodeURI (js/decodeURI %))))]
- (cond (string/blank? path)
- (safe-encode-url dir)
- (string/blank? dir)
- (safe-encode-url path)
- (string/starts-with? path dir)
- (safe-encode-url path)
- :else
- (let [path' (safe-encode-url path)]
- (str dir "/" path')))))
- (defn- local-container-path?
- "Check whether `path' is logseq's container `localDocumentsPath' on iOS"
- [path localDocumentsPath]
- (string/includes? path localDocumentsPath))
- (rum/defc instruction
- []
- [:div.instruction
- [:h1.title "Please choose a valid directory!"]
- [:p.leading-6 "Logseq app can only save or access your graphs stored in a specific directory with a "
- [:strong "Logseq icon"]
- " inside, located either in \"iCloud Drive\", \"On My iPhone\" or \"On My iPad\"."]
- [:p.leading-6 "Please watch the following short instruction video. "
- [:small.text-gray-500 "(may take few seconds to load...)"]]
- [:iframe
- {:src "https://www.loom.com/embed/dae612ae5fd94e508bd0acdf02efb888"
- :frame-border "0"
- :position "relative"
- :allow-full-screen "allowfullscreen"
- :webkit-allow-full-screen "webkitallowfullscreen"
- :height "100%"}]])
- (defn- open-dir
- [dir]
- (p/let [_ (when (mobile-util/native-android?) (android-check-permission))
- {:keys [path localDocumentsPath]} (-> (.pickFolder mobile-util/folder-picker
- (clj->js (when (and dir (mobile-util/native-ios?))
- {:path dir})))
- (p/then #(js->clj % :keywordize-keys true))
- (p/catch (fn [e]
- (js/alert (str e))
- nil))) ;; NOTE: If pick folder fails, let it crash
- _ (when (and (mobile-util/native-ios?)
- (not (or (local-container-path? path localDocumentsPath)
- (mobile-util/iCloud-container-path? path))))
- (state/pub-event! [:modal/show-instruction]))
- _ (js/console.log "Opening or Creating graph at directory: " path)
- files (readdir path)
- files (js->clj files :keywordize-keys true)]
- (into [] (concat [{:path path}] files))))
- (defrecord ^:large-vars/cleanup-todo Capacitorfs []
- protocol/Fs
- (mkdir! [_this dir]
- (let [dir' (normalize-file-protocol-path "" dir)]
- (-> (.mkdir Filesystem
- (clj->js
- {:path dir'}))
- (p/catch (fn [error]
- (log/error :mkdir! {:path dir'
- :error error}))))))
- (mkdir-recur! [_this dir]
- (p/let
- [_ (-> (.mkdir Filesystem
- (clj->js
- {:path dir
- :recursive true}))
- (p/catch (fn [error]
- (log/error :mkdir-recur! {:path dir
- :error error}))))
- stat (<stat dir)]
- (if (= (:type stat) "directory")
- (p/resolved true)
- (p/rejected (js/Error. "mkdir-recur! failed")))))
- (readdir [_this dir] ; recursive
- (let [dir (if-not (string/starts-with? dir "file://")
- (str "file://" dir)
- dir)]
- (readdir dir)))
- (unlink! [this repo path _opts]
- (p/let [path (normalize-file-protocol-path nil path)
- repo-url (config/get-local-dir repo)
- recycle-dir (util/safe-path-join repo-url config/app-name ".recycle") ;; logseq/.recycle
- ;; convert url to pure path
- file-name (-> (string/replace path repo-url "")
- (string/replace "/" "_")
- (string/replace "\\" "_"))
- new-path (str recycle-dir "/" file-name)
- _ (protocol/mkdir-recur! this recycle-dir)]
- (protocol/rename! this repo path new-path)))
- (rmdir! [_this _dir]
- ;; Too dangerous!!! We'll never implement this.
- nil)
- (read-file [_this dir path _options]
- (let [path (normalize-file-protocol-path dir path)]
- (->
- (<read-file-with-utf8 path)
- (p/catch (fn [error]
- (log/error :read-file-failed error))))))
- (write-file! [this repo dir path content opts]
- (let [path (normalize-file-protocol-path dir path)]
- (p/let [stat (p/catch
- (.stat Filesystem (clj->js {:path path}))
- (fn [_e] :not-found))]
- ;; `path` is full-path
- (write-file-impl! this repo dir path content opts stat))))
- (rename! [_this _repo old-path new-path]
- (let [[old-path new-path] (map #(normalize-file-protocol-path "" %) [old-path new-path])]
- (p/catch
- (p/let [_ (.rename Filesystem
- (clj->js
- {:from old-path
- :to new-path}))])
- (fn [error]
- (log/error :rename-file-failed error)))))
- (copy! [_this _repo old-path new-path]
- (let [[old-path new-path] (map #(normalize-file-protocol-path "" %) [old-path new-path])]
- (p/catch
- (p/let [_ (.copy Filesystem
- (clj->js
- {:from old-path
- :to new-path}))])
- (fn [error]
- (log/error :copy-file-failed error)))))
- (stat [_this dir path]
- (let [path (normalize-file-protocol-path dir path)]
- (p/chain (.stat Filesystem (clj->js {:path path}))
- #(js->clj % :keywordize-keys true))))
- (open-dir [_this dir _ok-handler]
- (open-dir dir))
- (get-files [_this path-or-handle _ok-handler]
- (readdir path-or-handle))
- (watch-dir! [_this dir _options]
- (p/do!
- (.unwatch mobile-util/fs-watcher)
- (.watch mobile-util/fs-watcher (clj->js {:path dir}))))
- (unwatch-dir! [_this _dir]
- (.unwatch mobile-util/fs-watcher)))
|