capacitor_fs.cljs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. (ns frontend.fs.capacitor-fs
  2. "Implementation of fs protocol for mobile"
  3. (:require ["@capacitor/filesystem" :refer [Encoding Filesystem Directory]]
  4. [cljs-bean.core :as bean]
  5. [clojure.string :as string]
  6. [goog.string :as gstring]
  7. [frontend.config :as config]
  8. [frontend.db :as db]
  9. [frontend.encrypt :as encrypt]
  10. [frontend.fs.protocol :as protocol]
  11. [frontend.mobile.util :as mobile-util]
  12. [frontend.state :as state]
  13. [frontend.util :as util]
  14. [lambdaisland.glogi :as log]
  15. [promesa.core :as p]
  16. [rum.core :as rum]))
  17. (when (mobile-util/native-ios?)
  18. (defn ios-ensure-documents!
  19. []
  20. (.ensureDocuments mobile-util/ios-file-container)))
  21. (when (mobile-util/native-android?)
  22. (defn- android-check-permission []
  23. (p/let [permission (.checkPermissions Filesystem)
  24. permission (-> permission
  25. bean/->clj
  26. :publicStorage)]
  27. (when-not (= permission "granted")
  28. (p/do!
  29. (.requestPermissions Filesystem))))))
  30. (defn- <write-file-with-utf8
  31. [path content]
  32. (when-not (string/blank? path)
  33. (-> (p/chain (.writeFile Filesystem (clj->js {:path path
  34. :data content
  35. :encoding (.-UTF8 Encoding)
  36. :recursive true}))
  37. #(js->clj % :keywordize-keys true))
  38. (p/catch (fn [error]
  39. (js/console.error "writeFile Error: " path ": " error)
  40. nil)))))
  41. (defn- <read-file-with-utf8
  42. [path]
  43. (when-not (string/blank? path)
  44. (-> (p/chain (.readFile Filesystem (clj->js {:path path
  45. :encoding (.-UTF8 Encoding)}))
  46. #(js->clj % :keywordize-keys true)
  47. #(get % :data nil))
  48. (p/catch (fn [error]
  49. (js/console.error "readFile Error: " path ": " error)
  50. nil)))))
  51. (defn- <readdir [path]
  52. (-> (p/chain (.readdir Filesystem (clj->js {:path path}))
  53. #(js->clj % :keywordize-keys true)
  54. :files)
  55. (p/catch (fn [error]
  56. (js/console.error "readdir Error: " path ": " error)
  57. nil))))
  58. (defn- <stat [path]
  59. (-> (p/chain (.stat Filesystem (clj->js {:path path}))
  60. #(js->clj % :keywordize-keys true))
  61. (p/catch (fn [error]
  62. (js/console.error "stat Error: " path ": " error)
  63. nil))))
  64. (defn readdir
  65. "readdir recursively"
  66. [path]
  67. (p/let [result (p/loop [result []
  68. dirs [path]]
  69. (if (empty? dirs)
  70. result
  71. (p/let [d (first dirs)
  72. files (<readdir d)
  73. files (->> files
  74. (remove (fn [{:keys [name type]}]
  75. (or (string/starts-with? name ".")
  76. (and (= type "directory")
  77. (or (= name "bak")
  78. (= name "version-files")))))))
  79. files-dir (->> files
  80. (filterv #(= (:type %) "directory"))
  81. (mapv :uri))
  82. files-result
  83. (p/all
  84. (->> files
  85. (filter #(= (:type %) "file"))
  86. (filter
  87. (fn [{:keys [uri]}]
  88. (some #(string/ends-with? uri %)
  89. [".md" ".markdown" ".org" ".edn" ".css"])))
  90. (mapv
  91. (fn [{:keys [uri] :as file-info}]
  92. (p/chain (<read-file-with-utf8 uri)
  93. #(assoc file-info :content %))))))]
  94. (p/recur (concat result files-result)
  95. (concat (rest dirs) files-dir)))))]
  96. (js->clj result :keywordize-keys true)))
  97. (defn- contents-matched?
  98. [disk-content db-content]
  99. (when (and (string? disk-content) (string? db-content))
  100. (if (encrypt/encrypted-db? (state/get-current-repo))
  101. (p/let [decrypted-content (encrypt/decrypt disk-content)]
  102. (= (string/trim decrypted-content) (string/trim db-content)))
  103. (p/resolved (= (string/trim disk-content) (string/trim db-content))))))
  104. (def backup-dir "logseq/bak")
  105. (def version-file-dir "logseq/version-files/local")
  106. (defn- get-backup-dir
  107. [repo-dir path bak-dir ext]
  108. (let [relative-path (-> path
  109. (string/replace (re-pattern (str "^" (gstring/regExpEscape repo-dir)))
  110. "")
  111. (string/replace (re-pattern (str "(?i)" (gstring/regExpEscape (str "." ext)) "$"))
  112. ""))]
  113. (util/safe-path-join repo-dir (str bak-dir "/" relative-path))))
  114. (defn- truncate-old-versioned-files!
  115. "reserve the latest 6 version files"
  116. [dir]
  117. (->
  118. (p/let [files (readdir dir)
  119. files (js->clj files :keywordize-keys true)
  120. old-versioned-files (drop 6 (reverse (sort-by :mtime files)))]
  121. (mapv (fn [file]
  122. (.deleteFile Filesystem (clj->js {:path (:uri file)})))
  123. old-versioned-files))
  124. (p/catch (fn [_]))))
  125. ;; TODO: move this to FS protocol
  126. (defn backup-file
  127. "backup CONTENT under DIR :backup-dir or :version-file-dir
  128. :backup-dir = `backup-dir`
  129. :version-file-dir = `version-file-dir`"
  130. [repo dir path content]
  131. {:pre [(contains? #{:backup-dir :version-file-dir} dir)]}
  132. (let [repo-dir (config/get-local-dir repo)
  133. ext (util/get-file-ext path)
  134. dir (case dir
  135. :backup-dir (get-backup-dir repo-dir path backup-dir ext)
  136. :version-file-dir (get-backup-dir repo-dir path version-file-dir ext))
  137. new-path (util/safe-path-join dir (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) "." ext))]
  138. (<write-file-with-utf8 new-path content)
  139. (truncate-old-versioned-files! dir)))
  140. (defn backup-file-handle-changed!
  141. [repo-dir file-path content]
  142. (let [divider-schema "://"
  143. file-schema (string/split file-path divider-schema)
  144. file-schema (if (> (count file-schema) 1) (first file-schema) "")
  145. dir-schema? (and (string? repo-dir)
  146. (string/includes? repo-dir divider-schema))
  147. repo-dir (if-not dir-schema?
  148. (str file-schema divider-schema repo-dir) repo-dir)
  149. backup-root (util/safe-path-join repo-dir backup-dir)
  150. backup-dir-parent (util/node-path.dirname file-path)
  151. backup-dir-parent (string/replace backup-dir-parent repo-dir "")
  152. backup-dir-name (util/node-path.name file-path)
  153. file-extname (.extname util/node-path file-path)
  154. file-root (util/safe-path-join backup-root backup-dir-parent backup-dir-name)
  155. file-path (util/safe-path-join file-root
  156. (str (string/replace (.toISOString (js/Date.)) ":" "_") "." (mobile-util/platform) file-extname))]
  157. (<write-file-with-utf8 file-path content)
  158. (truncate-old-versioned-files! file-root)))
  159. (defn- write-file-impl!
  160. [_this repo _dir path content {:keys [ok-handler error-handler old-content skip-compare?]} stat]
  161. (if (or (string/blank? repo) skip-compare?)
  162. (p/catch
  163. (p/let [result (<write-file-with-utf8 path content)]
  164. (when ok-handler
  165. (ok-handler repo path result)))
  166. (fn [error]
  167. (if error-handler
  168. (error-handler error)
  169. (log/error :write-file-failed error))))
  170. ;; Compare with disk content and backup if not equal
  171. (p/let [disk-content (<read-file-with-utf8 path)
  172. disk-content (or disk-content "")
  173. repo-dir (config/get-local-dir repo)
  174. ext (util/get-file-ext path)
  175. db-content (or old-content (db/get-file repo path) "")
  176. contents-matched? (contents-matched? disk-content db-content)]
  177. (cond
  178. (and
  179. (not= stat :not-found) ; file on the disk was deleted
  180. (not contents-matched?)
  181. (not (contains? #{"excalidraw" "edn" "css"} ext))
  182. (not (string/includes? path "/.recycle/")))
  183. (p/let [disk-content (encrypt/decrypt disk-content)]
  184. (state/pub-event! [:file/not-matched-from-disk path disk-content content]))
  185. :else
  186. (->
  187. (p/let [result (<write-file-with-utf8 path content)
  188. mtime (-> (js->clj stat :keywordize-keys true)
  189. :mtime)]
  190. (when-not contents-matched?
  191. (backup-file repo-dir :backup-dir path disk-content))
  192. (db/set-file-last-modified-at! repo path mtime)
  193. (p/let [content (if (encrypt/encrypted-db? (state/get-current-repo))
  194. (encrypt/decrypt content)
  195. content)]
  196. (db/set-file-content! repo path content))
  197. (when ok-handler
  198. (ok-handler repo path result))
  199. result)
  200. (p/catch (fn [error]
  201. (if error-handler
  202. (error-handler error)
  203. (log/error :write-file-failed error)))))))))
  204. (defn normalize-file-protocol-path [dir path]
  205. (let [dir (some-> dir (string/replace #"/+$" ""))
  206. dir (if (and (not-empty dir) (string/starts-with? dir "/"))
  207. (do
  208. (js/console.trace "WARN: detect absolute path, use URL instead")
  209. (str "file://" (js/encodeURI dir)))
  210. dir)
  211. path (some-> path (string/replace #"^/+" ""))
  212. safe-encode-url #(let [encoded-chars?
  213. (and (string? %) (boolean (re-find #"(?i)%[0-9a-f]{2}" %)))]
  214. (cond
  215. (not encoded-chars?)
  216. (js/encodeURI %)
  217. :else
  218. (js/encodeURI (js/decodeURI %))))]
  219. (cond (string/blank? path)
  220. (safe-encode-url dir)
  221. (string/blank? dir)
  222. (safe-encode-url path)
  223. (string/starts-with? path dir)
  224. (safe-encode-url path)
  225. :else
  226. (let [path' (safe-encode-url path)]
  227. (str dir "/" path')))))
  228. (defn- local-container-path?
  229. "Check whether `path' is logseq's container `localDocumentsPath' on iOS"
  230. [path localDocumentsPath]
  231. (string/includes? path localDocumentsPath))
  232. (rum/defc instruction
  233. []
  234. [:div.instruction
  235. [:h1.title "Please choose a valid directory!"]
  236. [:p.leading-6 "Logseq app can only save or access your graphs stored in a specific directory with a "
  237. [:strong "Logseq icon"]
  238. " inside, located either in \"iCloud Drive\", \"On My iPhone\" or \"On My iPad\"."]
  239. [:p.leading-6 "Please watch the following short instruction video. "
  240. [:small.text-gray-500 "(may take few seconds to load...)"]]
  241. [:iframe
  242. {:src "https://www.loom.com/embed/dae612ae5fd94e508bd0acdf02efb888"
  243. :frame-border "0"
  244. :position "relative"
  245. :allow-full-screen "allowfullscreen"
  246. :webkit-allow-full-screen "webkitallowfullscreen"
  247. :height "100%"}]])
  248. (defn- open-dir
  249. [dir]
  250. (p/let [_ (when (mobile-util/native-android?) (android-check-permission))
  251. {:keys [path localDocumentsPath]} (-> (.pickFolder mobile-util/folder-picker
  252. (clj->js (when (and dir (mobile-util/native-ios?))
  253. {:path dir})))
  254. (p/then #(js->clj % :keywordize-keys true))
  255. (p/catch (fn [e]
  256. (js/alert (str e))
  257. nil))) ;; NOTE: If pick folder fails, let it crash
  258. _ (when (and (mobile-util/native-ios?)
  259. (not (or (local-container-path? path localDocumentsPath)
  260. (mobile-util/iCloud-container-path? path))))
  261. (state/pub-event! [:modal/show-instruction]))
  262. _ (js/console.log "Opening or Creating graph at directory: " path)
  263. files (readdir path)
  264. files (js->clj files :keywordize-keys true)]
  265. (into [] (concat [{:path path}] files))))
  266. (defrecord ^:large-vars/cleanup-todo Capacitorfs []
  267. protocol/Fs
  268. (mkdir! [_this dir]
  269. (let [dir' (normalize-file-protocol-path "" dir)]
  270. (-> (.mkdir Filesystem
  271. (clj->js
  272. {:path dir'}))
  273. (p/catch (fn [error]
  274. (log/error :mkdir! {:path dir'
  275. :error error}))))))
  276. (mkdir-recur! [_this dir]
  277. (p/let
  278. [_ (-> (.mkdir Filesystem
  279. (clj->js
  280. {:path dir
  281. :recursive true}))
  282. (p/catch (fn [error]
  283. (log/error :mkdir-recur! {:path dir
  284. :error error}))))
  285. stat (<stat dir)]
  286. (if (= (:type stat) "directory")
  287. (p/resolved true)
  288. (p/rejected (js/Error. "mkdir-recur! failed")))))
  289. (readdir [_this dir] ; recursive
  290. (let [dir (if-not (string/starts-with? dir "file://")
  291. (str "file://" dir)
  292. dir)]
  293. (readdir dir)))
  294. (unlink! [this repo path _opts]
  295. (p/let [path (normalize-file-protocol-path nil path)
  296. repo-url (config/get-local-dir repo)
  297. recycle-dir (util/safe-path-join repo-url config/app-name ".recycle") ;; logseq/.recycle
  298. ;; convert url to pure path
  299. file-name (-> (string/replace path repo-url "")
  300. (string/replace "/" "_")
  301. (string/replace "\\" "_"))
  302. new-path (str recycle-dir "/" file-name)
  303. _ (protocol/mkdir-recur! this recycle-dir)]
  304. (protocol/rename! this repo path new-path)))
  305. (rmdir! [_this _dir]
  306. ;; Too dangerous!!! We'll never implement this.
  307. nil)
  308. (read-file [_this dir path _options]
  309. (let [path (normalize-file-protocol-path dir path)]
  310. (->
  311. (<read-file-with-utf8 path)
  312. (p/catch (fn [error]
  313. (log/error :read-file-failed error))))))
  314. (write-file! [this repo dir path content opts]
  315. (let [path (normalize-file-protocol-path dir path)]
  316. (p/let [stat (p/catch
  317. (.stat Filesystem (clj->js {:path path}))
  318. (fn [_e] :not-found))]
  319. ;; `path` is full-path
  320. (write-file-impl! this repo dir path content opts stat))))
  321. (rename! [_this _repo old-path new-path]
  322. (let [[old-path new-path] (map #(normalize-file-protocol-path "" %) [old-path new-path])]
  323. (p/catch
  324. (p/let [_ (.rename Filesystem
  325. (clj->js
  326. {:from old-path
  327. :to new-path}))])
  328. (fn [error]
  329. (log/error :rename-file-failed error)))))
  330. (copy! [_this _repo old-path new-path]
  331. (let [[old-path new-path] (map #(normalize-file-protocol-path "" %) [old-path new-path])]
  332. (p/catch
  333. (p/let [_ (.copy Filesystem
  334. (clj->js
  335. {:from old-path
  336. :to new-path}))])
  337. (fn [error]
  338. (log/error :copy-file-failed error)))))
  339. (stat [_this dir path]
  340. (let [path (normalize-file-protocol-path dir path)]
  341. (p/chain (.stat Filesystem (clj->js {:path path}))
  342. #(js->clj % :keywordize-keys true))))
  343. (open-dir [_this dir _ok-handler]
  344. (open-dir dir))
  345. (get-files [_this path-or-handle _ok-handler]
  346. (readdir path-or-handle))
  347. (watch-dir! [_this dir _options]
  348. (p/do!
  349. (.unwatch mobile-util/fs-watcher)
  350. (.watch mobile-util/fs-watcher (clj->js {:path dir}))))
  351. (unwatch-dir! [_this _dir]
  352. (.unwatch mobile-util/fs-watcher)))