feat: db auto backup on desktop (#12275)

* feat: db auto backup on desktop
* fix: press delete closes right sidebar

fix https://github.com/logseq/db-test/issues/670

* disable git backup for db graphs since it's confusing

Users can still use external Git for version control.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tienson Qin
2025-12-25 10:40:59 +08:00
committed by GitHub
parent afd4210906
commit cf80caebf6
10 changed files with 237 additions and 93 deletions

View File

@@ -4,8 +4,8 @@
(:require ["path" :as node-path]
[clojure.string :as string]
[datascript.core :as d]
[logseq.db.sqlite.util :as sqlite-util]
[logseq.common.config :as common-config]))
[logseq.common.config :as common-config]
[logseq.db.sqlite.util :as sqlite-util]))
(defn create-kvs-table!
"Creates a sqlite table for use with datascript.storage if one doesn't exist"
@@ -40,3 +40,8 @@
(let [db-name' (sanitize-db-name db-name)
graph-dir (node-path/join graphs-dir db-name')]
[db-name' (node-path/join graph-dir "db.sqlite")]))
(defn get-db-backups-path
[graphs-dir db-name]
(let [db-name' (sanitize-db-name db-name)]
(node-path/join graphs-dir db-name' "backups")))

View File

@@ -1,8 +1,8 @@
(ns electron.backup-file
(:require [clojure.string :as string]
(:require ["fs" :as fs]
["fs-extra" :as fs-extra]
["path" :as node-path]
["fs" :as fs]
["fs-extra" :as fs-extra]))
[clojure.string :as string]))
(def backup-dir "logseq/bak")
(def version-file-dir "logseq/version-files/local")
@@ -24,38 +24,142 @@
[repo relative-path]
(get-backup-dir* repo relative-path version-file-dir))
;; TODO: add interval support like days
(defn- truncate-old-versioned-files!
"reserve the latest 6 version files"
[dir]
(let [files (fs/readdirSync dir (clj->js {:withFileTypes true}))
files (mapv #(.-name %) files)
old-versioned-files (drop 6 (reverse (sort files)))]
"reserve the latest `keep-versions` version files"
[dir keep-versions]
(let [entries (fs/readdirSync dir (clj->js {:withFileTypes true}))
files (->> entries
(filter #(.-isFile %))
(mapv #(.-name %)))
old-versioned-files (drop keep-versions (reverse (sort files)))]
(doseq [file old-versioned-files]
(fs-extra/removeSync (node-path/join dir file)))))
(defn- parse-backup-ts
"Backup filenames are like: 2025-12-25T01_23_45.678Z.ext
We turn '_' back into ':' and parse as ISO."
[filename]
(let [base (-> filename
;; drop extension (keep last '.' part)
(string/replace #"\.[^.]+$" "")
(string/replace "_" ":"))
ms (.parse js/Date base)]
(when-not (js/isNaN ms) ms)))
(defn- truncate-daily-versioned-files!
"Keep the latest `keep-versions` version files, but:
- the newest 6 kept are deduped per-hour (keep newest file per hour)
- the remaining kept (if any) are deduped per-day (keep newest file per day)
Example: keep-versions=12 => 6 hourly + 6 daily."
[dir keep-versions]
(let [keep-versions (max 0 (or keep-versions 0))
keep-hourly (min 6 keep-versions)
;; list file names (ignore directories)
dirents (fs/readdirSync dir (clj->js {:withFileTypes true}))
files (->> dirents
(filter #(.-isFile %))
(mapv #(.-name %)))
;; sort newest -> oldest primarily by parsed timestamp; fall back to name
files* (->> files
(map (fn [n] {:name n :ts (or (parse-backup-ts n) -1)}))
(sort-by (juxt (comp - :ts) :name))
(mapv :name))
;; decide which files to keep
keep-set
(loop [xs files*
kept #{}
kept-count 0
hour-seen #{}
day-seen #{}]
(if (or (empty? xs) (>= kept-count keep-versions))
kept
(let [f (first xs)
ts (parse-backup-ts f)
;; derive keys; if unparsable, treat as unique bucket
hour-key (if ts
(.toISOString (js/Date. (-> ts
(js/Math.floor)
(- (mod ts 3600000)))))
(str "unparsable-hour:" f))
day-key (if ts
(.slice (.toISOString (js/Date. ts)) 0 10)
(str "unparsable-day:" f))]
(cond
;; Phase 1: hourly buckets (newest 6 hours)
(< (count hour-seen) keep-hourly)
(if (contains? hour-seen hour-key)
(recur (rest xs) kept kept-count hour-seen day-seen)
(recur (rest xs)
(conj kept f)
(inc kept-count)
(conj hour-seen hour-key)
day-seen))
;; Phase 2: daily buckets (fill remaining up to keep-versions)
:else
(if (contains? day-seen day-key)
(recur (rest xs) kept kept-count hour-seen day-seen)
(recur (rest xs)
(conj kept f)
(inc kept-count)
hour-seen
(conj day-seen day-key)))))))
;; remove everything not in keep-set
to-remove (remove keep-set files)]
(doseq [file to-remove]
(fs-extra/removeSync (node-path/join dir file)))))
(defn- latest-backup-info
"Return {:name .. :ts .. :size ..} for the latest backup in dir, or nil.
Prefers timestamp parsed from filename; falls back to file mtimeMs."
[dir]
(let [dirents (fs/readdirSync dir (clj->js {:withFileTypes true}))
files (->> dirents (filter #(.-isFile %)) (map #(.-name %)))]
(when (seq files)
(->> files
(map (fn [name]
(let [p (node-path/join dir name)
stat (fs/statSync p)
ts (or (parse-backup-ts name) (.-mtimeMs stat))]
{:name name
:ts ts
:size (.-size stat)})))
(apply max-key :ts)))))
(defn- too-soon?
[dir]
(let [info (latest-backup-info dir)
;; default: if using daily+hourly retention, dont create more than 1 per hour
min-interval-ms 3600000
now-ms (.now js/Date)
latest-backup-ts (:ts info)]
(and latest-backup-ts
(pos? min-interval-ms)
(< (- now-ms latest-backup-ts) min-interval-ms))))
(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 relative-path ext content & {:keys [add-desktop? skip-backup-fn]
:or {add-desktop? true}}]
{:pre [(contains? #{:backup-dir :version-file-dir} dir)]}
(let [dir* (case dir
:backup-dir (get-backup-dir repo relative-path)
:version-file-dir (get-version-file-dir repo relative-path))
[repo dir relative-path ext content & {:keys [truncate-daily?
keep-versions backups-dir]
:or {keep-versions 6}}]
(let [dir* (or backups-dir
(case dir
:backup-dir (get-backup-dir repo relative-path)
:version-file-dir (get-version-file-dir repo relative-path)))
_ (fs-extra/ensureDirSync dir*)
backups (fs/readdirSync dir*)
latest-backup-size (when (seq backups)
(some->> (nth backups (dec (count backups)))
(node-path/join dir*)
(fs/statSync)
(.-size)))]
(when-not (and (fn? skip-backup-fn) latest-backup-size (skip-backup-fn latest-backup-size))
(let [new-path (node-path/join dir*
(str (string/replace (.toISOString (js/Date.)) ":" "_")
(when add-desktop? ".Desktop")
ext))]
(fs/writeFileSync new-path content)
(fs/statSync new-path)
(truncate-old-versioned-files! dir*)))))
new-path (node-path/join dir*
(str (string/replace (.toISOString (js/Date.)) ":" "_")
ext))]
(when-not (and truncate-daily? (too-soon? dir*))
(fs/writeFileSync new-path content)
(fs/statSync new-path)
(if truncate-daily?
(truncate-daily-versioned-files! dir* keep-versions)
(truncate-old-versioned-files! dir* keep-versions)))))

View File

@@ -2,6 +2,7 @@
"Provides SQLite dbs for electron and manages files of those dbs"
(:require ["fs-extra" :as fs]
["path" :as node-path]
[electron.backup-file :as backup-file]
[logseq.cli.common.graph :as cli-common-graph]
[logseq.common.config :as common-config]
[logseq.db.common.sqlite :as common-sqlite]))
@@ -17,11 +18,6 @@
(fs/ensureDirSync graph-dir)
graph-dir))
(defn save-db!
[db-name data]
(let [[_db-name db-path] (common-sqlite/get-db-full-path (cli-common-graph/get-db-graphs-dir) db-name)]
(fs/writeFileSync db-path data)))
(defn get-db
[db-name]
(let [_ (ensure-graph-dir! db-name)
@@ -29,6 +25,20 @@
(when (fs/existsSync db-path)
(fs/readFileSync db-path))))
(defn save-db!
[db-name data]
(let [[db-name db-path] (common-sqlite/get-db-full-path (cli-common-graph/get-db-graphs-dir) db-name)
old-data (get-db db-name)
backups-path (common-sqlite/get-db-backups-path (cli-common-graph/get-db-graphs-dir) db-name)]
(when old-data
(backup-file/backup-file db-name nil nil
".sqlite"
old-data
{:backups-dir backups-path
:truncate-daily? true
:keep-versions 12}))
(fs/writeFileSync db-path data)))
(defn unlink-graph!
[repo]
(let [db-name (common-sqlite/sanitize-db-name repo)

View File

@@ -1,14 +1,14 @@
(ns electron.git
(:require ["dugite" :refer [GitProcess]]
[goog.object :as gobj]
["fs-extra" :as fs]
["os" :as os]
["path" :as node-path]
[clojure.string :as string]
[electron.logger :as logger]
[electron.state :as state]
[electron.utils :as utils]
[electron.logger :as logger]
[promesa.core :as p]
[clojure.string :as string]
["fs-extra" :as fs]
["path" :as node-path]
["os" :as os]))
[goog.object :as gobj]
[promesa.core :as p]))
(def log-error (partial logger/error "[Git]"))
@@ -111,27 +111,30 @@
(defn add-all-and-commit-single-graph!
[graph-path message]
(let [message (if (string/blank? message)
"Auto saved by Logseq"
message)]
(->
(p/let [_ (init! graph-path)
_ (add-all! graph-path)]
(commit! graph-path message))
(p/catch (fn [error]
(when (and
(string? error)
(not (string/blank? error)))
(if (string/starts-with? error "Author identity unknown")
(utils/send-to-renderer "setGitUsernameAndEmail" {:type "git"})
(utils/send-to-renderer "notification" {:type "error"
:payload (str error "\nIf you don't want to see those errors or don't need git, you can disable the \"Git auto commit\" feature on Settings > Version control.")}))))))))
;; Don't run git on db graphs
(when (string/includes? graph-path "logseq_local_")
(let [message (if (string/blank? message)
"Auto saved by Logseq"
message)]
(->
(p/let [_ (init! graph-path)
_ (add-all! graph-path)]
(commit! graph-path message))
(p/catch (fn [error]
(when (and
(string? error)
(not (string/blank? error)))
(if (string/starts-with? error "Author identity unknown")
(utils/send-to-renderer "setGitUsernameAndEmail" {:type "git"})
(utils/send-to-renderer "notification" {:type "error"
:payload (str error "\nIf you don't want to see those errors or don't need git, you can disable the \"Git auto commit\" feature on Settings > Version control.")})))))))))
(defn add-all-and-commit!
([]
(add-all-and-commit! nil))
([message]
(doseq [path (state/get-all-graph-paths)] (add-all-and-commit-single-graph! path message))))
(doseq [path (state/get-all-graph-paths)]
(add-all-and-commit-single-graph! path message))))
(defn short-status!
[graph-path]

View File

@@ -63,14 +63,14 @@
{:variant :default
:on-click (fn []
(->
(p/let [result (export/backup-db-graph repo :set-folder)]
(p/let [result (export/backup-db-graph repo)]
(case result
true
(notification/show! "Backup successful!" :success)
:graph-not-changed
(notification/show! "Graph has not been updated since last export." :success)
nil)
(export/auto-db-backup! repo {:backup-now? false}))
(export/auto-db-backup! repo))
(p/catch (fn [error]
(println "Failed to backup.")
(js/console.error error)))))}
@@ -139,7 +139,9 @@
"Export debug transit file"]
[:p.text-sm.opacity-70.mb-0 "Exports to a .transit file to send to us for debugging. Any sensitive data will be removed in the exported file."]])
(when (and db-based? (not (util/mobile?)))
(when (and db-based?
util/web-platform?
(not (util/mobile?)))
[:div
[:hr]
(auto-backup)])]])))

View File

@@ -801,7 +801,8 @@
(tooltip-row t enable-tooltip?))
(timetracking-row t enable-timetracking?)
(enable-all-pages-public-row t enable-all-pages-public?)
(auto-push-row t current-repo enable-git-auto-push?)]))
(when-not db-graph?
(auto-push-row t current-repo enable-git-auto-push?))]))
(rum/defc settings-git
[]
@@ -1505,7 +1506,7 @@
(when db-based?
[:ai (t :settings-page/tab-ai) (t :settings-page/ai) (ui/icon "wand")])
(when (util/electron?)
(when (and (util/electron?) (not db-based?))
[:version-control "git" (t :settings-page/tab-version-control) (ui/icon "history")])
;; (when (util/electron?)

View File

@@ -113,19 +113,25 @@
(repo-handler/refresh-repos!))))
(defmethod handle :graph/switch [[_ graph opts]]
(export/cancel-db-backup!)
(persist-db/export-current-graph!)
(state/set-state! :db/async-queries {})
(st/refresh!)
(p/let [writes-finished? (state/<invoke-db-worker :thread-api/file-writes-finished? (state/get-current-repo))]
(if (not writes-finished?) ; TODO: test (:sync-graph/init? @state/state)
(do
(log/info :graph/switch {:file-writes-finished? writes-finished?})
(notification/show!
"Please wait seconds until all changes are saved for the current graph."
:warning))
(graph-switch-on-persisted graph opts))))
(let [switch-promise
(p/do!
(export/cancel-db-backup!)
(persist-db/export-current-graph!)
(state/set-state! :db/async-queries {})
(st/refresh!)
(if (config/db-based-graph?)
(graph-switch-on-persisted graph opts)
(p/let [writes-finished? (state/<invoke-db-worker :thread-api/file-writes-finished? (state/get-current-repo))]
(if (not writes-finished?) ; TODO: test (:sync-graph/init? @state/state)
(do
(log/info :graph/switch {:file-writes-finished? writes-finished?})
(notification/show!
"Please wait seconds until all changes are saved for the current graph."
:warning))
(graph-switch-on-persisted graph opts)))))]
(p/then switch-promise
(fn [_]
(export/backup-db-graph (state/get-current-repo))))))
(defmethod handle :graph/open-new-window [[_ev target-repo]]
(ui-handler/open-new-window-or-tab! target-repo))
@@ -259,8 +265,7 @@
(defmethod handle :graph/restored [[_ graph]]
(when graph (assets-handler/ensure-assets-dir! graph))
(state/pub-event! [:graph/sync-context])
(when (config/db-based-graph? graph)
(export/auto-db-backup! graph {:backup-now? true}))
(export/auto-db-backup! graph)
(rtc-flows/trigger-rtc-start graph)
(fsrs/update-due-cards-count)
(when-not (mobile-util/native-platform?)

View File

@@ -264,8 +264,8 @@
(db/transact! [(ldb/kv :logseq.kv/graph-backup-folder folder-name)])
[folder-name handle]))
(defn backup-db-graph
[repo _backup-type]
(defn- web-backup-db-graph
[repo]
(when (and repo (= repo (state/get-current-repo)))
(when-let [backup-folder (ldb/get-key-value (db/get-db repo) :logseq.kv/graph-backup-folder)]
;; ensure file handle exists
@@ -310,6 +310,11 @@
(notification/show! "DB backup failed, please go to Export and specify a backup folder." :error)
false))))))
(defn backup-db-graph
[repo]
(when (and (config/db-based-graph? repo) (not (util/capacitor?)))
(web-backup-db-graph repo)))
(defonce *backup-interval (atom nil))
(defn cancel-db-backup!
[]
@@ -317,15 +322,15 @@
(js/clearInterval i)))
(defn auto-db-backup!
[repo {:keys [backup-now?]
:or {backup-now? true}}]
(when (ldb/get-key-value (db/get-db repo) :logseq.kv/graph-backup-folder)
(when (and (config/db-based-graph? repo) (not (util/capacitor?)))
(cancel-db-backup!)
[repo]
(when (and
(config/db-based-graph? repo)
util/web-platform?
(not (util/capacitor?))
(ldb/get-key-value (db/get-db repo) :logseq.kv/graph-backup-folder))
(cancel-db-backup!)
(when backup-now? (backup-db-graph repo :backup-now))
;; run backup every hour
(let [interval (js/setInterval #(backup-db-graph repo :auto)
(* 1 60 60 1000))]
(reset! *backup-interval interval)))))
;; run backup every hour
(let [interval (js/setInterval #(backup-db-graph repo)
(* 1 60 60 1000))]
(reset! *backup-interval interval))))

View File

@@ -50,7 +50,7 @@
(let [tx (@*last-synced-graph->tx repo)
db (db/get-db repo)]
(or (nil? tx)
(> tx (:max-tx db)))))
(> (:max-tx db) tx))))
(defn export-current-graph!
[& {:keys [succ-notification? force-save?]}]

View File

@@ -1301,12 +1301,21 @@ Similar to re-frame subscriptions"
[item]
(update-state! [:ui/navigation-item-collapsed? item] not))
(declare sidebar-add-block!)
(defn- sidebar-add-content-when-open!
[]
(when (empty? (:sidebar/blocks @state))
(sidebar-add-block! (get-current-repo) "contents" :contents)))
(defn toggle-sidebar-open?!
[]
(when-not (:ui/sidebar-open? @state)
(sidebar-add-content-when-open!))
(swap! state update :ui/sidebar-open? not))
(defn open-right-sidebar!
[]
(sidebar-add-content-when-open!)
(swap! state assoc :ui/sidebar-open? true))
(defn hide-right-sidebar!