Pārlūkot izejas kodu

Merge pull request #3839 from logseq/feat/file-sync

feat: file sync
icremcr 3 gadi atpakaļ
vecāks
revīzija
9ea8040ba5
40 mainītis faili ar 2105 papildinājumiem un 206 dzēšanām
  1. 1 0
      android/app/build.gradle
  2. 59 0
      android/app/src/main/java/com/logseq/app/GraphFileSync.java
  3. 1 0
      android/file-sync/.gitignore
  4. 36 0
      android/file-sync/build.gradle
  5. 0 0
      android/file-sync/consumer-rules.pro
  6. 21 0
      android/file-sync/proguard-rules.pro
  7. 25 0
      android/file-sync/src/androidTest/java/com/logseq/file_sync/ExampleInstrumentedTest.java
  8. 5 0
      android/file-sync/src/main/AndroidManifest.xml
  9. 14 0
      android/file-sync/src/main/java/com/logseq/file_sync/FileSync.java
  10. BIN
      android/file-sync/src/main/jniLibs/arm64-v8a/libfilesync.so
  11. BIN
      android/file-sync/src/main/jniLibs/armeabi-v7a/libfilesync.so
  12. BIN
      android/file-sync/src/main/jniLibs/x86/libfilesync.so
  13. BIN
      android/file-sync/src/main/jniLibs/x86_64/libfilesync.so
  14. 17 0
      android/file-sync/src/test/java/com/logseq/file_sync/ExampleUnitTest.java
  15. 2 1
      android/settings.gradle
  16. 7 0
      resources/forge.config.js
  17. 2 1
      resources/package.json
  18. 46 32
      src/electron/electron/core.cljs
  19. 26 0
      src/electron/electron/file_sync_rsapi.cljs
  20. 32 1
      src/electron/electron/handler.cljs
  21. 10 4
      src/main/electron/listener.cljs
  22. 1 1
      src/main/frontend/commands.cljs
  23. 121 48
      src/main/frontend/components/header.cljs
  24. 16 9
      src/main/frontend/components/page_menu.cljs
  25. 1 1
      src/main/frontend/components/repo.cljs
  26. 2 32
      src/main/frontend/components/settings.cljs
  27. 3 4
      src/main/frontend/components/sidebar.cljs
  28. 2 2
      src/main/frontend/components/svg.cljs
  29. 4 0
      src/main/frontend/config.cljs
  30. 6 2
      src/main/frontend/core.cljs
  31. 4 1
      src/main/frontend/dicts.cljc
  32. 1223 0
      src/main/frontend/fs/sync.cljs
  33. 5 1
      src/main/frontend/handler.cljs
  34. 15 2
      src/main/frontend/handler/events.cljs
  35. 93 0
      src/main/frontend/handler/file_sync.cljs
  36. 167 38
      src/main/frontend/handler/user.cljs
  37. 46 4
      src/main/frontend/state.cljs
  38. 21 0
      src/main/frontend/util.cljc
  39. 41 22
      src/main/frontend/util/persist_var.cljs
  40. 30 0
      static/yarn.lock

+ 1 - 0
android/app/build.gradle

@@ -38,6 +38,7 @@ dependencies {
     androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
     androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
     implementation project(':capacitor-cordova-android-plugins')
+    implementation project(':file-sync')
 }
 
 apply from: 'capacitor.build.gradle'

+ 59 - 0
android/app/src/main/java/com/logseq/app/GraphFileSync.java

@@ -0,0 +1,59 @@
+package com.logseq.app;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.logseq.file_sync.FileSync;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@CapacitorPlugin(name = "GraphFileSync")
+public class GraphFileSync extends Plugin {
+
+    //@PluginMethod(returnType = PluginMethod.RETURN_CALLBACK)
+    @PluginMethod()
+    public void watch(PluginCall call) {
+        String path = call.getString("path");
+        List<String> ignorePatterns = new ArrayList<>();
+        android.util.Log.i("FileSync", "path = " + path);
+
+        FileSync.ping();
+        String watched = FileSync.watch(this, path, ignorePatterns);
+        android.util.Log.i("FileSync", "started");
+        JSObject ret = new JSObject();
+        ret.put("path", watched);
+
+        // call.setKeepAlive(true);
+        call.resolve(ret);
+    }
+
+    public void notifyChange(String event, List<String> paths) {
+        android.util.Log.i("FileSync", "Event:" + event + " path: " + paths);
+        for (String p : paths) {
+            android.util.Log.i("FileSync", "Got path:" + p);
+        }
+        JSObject ret = new JSObject();
+        ret.put("event", event);
+        ret.put("path", paths);
+        notifyListeners(event, ret);
+    }
+
+    @PluginMethod()
+    public void close(PluginCall call) {
+        FileSync.close();
+        JSObject ret = new JSObject();
+        ret.put("value", "closed watcher");
+        call.resolve(ret);
+    }
+
+    @PluginMethod()
+    public void ping(PluginCall call) {
+        String res = FileSync.ping();
+        JSObject ret = new JSObject();
+        ret.put("value", res);
+        call.resolve(ret);
+    }
+}

+ 1 - 0
android/file-sync/.gitignore

@@ -0,0 +1 @@
+/build

+ 36 - 0
android/file-sync/build.gradle

@@ -0,0 +1,36 @@
+plugins {
+    id 'com.android.library'
+}
+
+android {
+    compileSdkVersion 30
+
+    defaultConfig {
+        minSdkVersion 21
+        targetSdkVersion 30
+        versionCode 1
+        versionName "1.0"
+
+        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+        consumerProguardFiles "consumer-rules.pro"
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+}
+
+dependencies {
+
+    implementation 'com.android.support:appcompat-v7:28.0.0'
+    testImplementation 'junit:junit:4.+'
+    androidTestImplementation 'com.android.support.test:runner:1.0.2'
+    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
+}

+ 0 - 0
android/file-sync/consumer-rules.pro


+ 21 - 0
android/file-sync/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 25 - 0
android/file-sync/src/androidTest/java/com/logseq/file_sync/ExampleInstrumentedTest.java

@@ -0,0 +1,25 @@
+package com.logseq.file_sync;
+
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+    @Test
+    public void useAppContext() {
+        // Context of the app under test.
+        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        assertEquals("com.logseq.file_sync.test", appContext.getPackageName());
+    }
+}

+ 5 - 0
android/file-sync/src/main/AndroidManifest.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.logseq.file_sync">
+
+</manifest>

+ 14 - 0
android/file-sync/src/main/java/com/logseq/file_sync/FileSync.java

@@ -0,0 +1,14 @@
+package com.logseq.file_sync;
+
+import java.util.List;
+
+public class FileSync {
+    static {
+        System.loadLibrary("filesync");
+    }
+
+    public static native String watch(final Object plugin, final String path, final List<String> ignorePatterns);
+
+    public static native void close();
+    public static native String ping();
+}

BIN
android/file-sync/src/main/jniLibs/arm64-v8a/libfilesync.so


BIN
android/file-sync/src/main/jniLibs/armeabi-v7a/libfilesync.so


BIN
android/file-sync/src/main/jniLibs/x86/libfilesync.so


BIN
android/file-sync/src/main/jniLibs/x86_64/libfilesync.so


+ 17 - 0
android/file-sync/src/test/java/com/logseq/file_sync/ExampleUnitTest.java

@@ -0,0 +1,17 @@
+package com.logseq.file_sync;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+public class ExampleUnitTest {
+    @Test
+    public void addition_isCorrect() {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 2 - 1
android/settings.gradle

@@ -2,4 +2,5 @@ include ':app'
 include ':capacitor-cordova-android-plugins'
 project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
 
-apply from: 'capacitor.settings.gradle'
+apply from: 'capacitor.settings.gradle'
+include ':file-sync'

+ 7 - 0
resources/forge.config.js

@@ -4,6 +4,13 @@ module.exports = {
   packagerConfig: {
     name: 'Logseq',
     icon: './icons/logseq_big_sur.icns',
+    protocols: [
+      {
+        "protocol":"logseq",
+        "name":"logseq",
+        "schemes":"logseq"
+      }
+    ],
     osxSign: {
       identity: 'Developer ID Application: Tiansheng Qin',
       'hardened-runtime': true,

+ 2 - 1
resources/package.json

@@ -35,7 +35,8 @@
     "diff-match-patch": "1.0.5",
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
-    "posthog-js": "1.10.2"
+    "posthog-js": "1.10.2",
+    "@andelf/rsapi": "0.0.5"
   },
   "devDependencies": {
     "@electron-forge/cli": "^6.0.0-beta.57",

+ 46 - 32
src/electron/electron/core.cljs

@@ -2,7 +2,7 @@
   (:require [electron.handler :as handler]
             [electron.search :as search]
             [electron.updater :refer [init-updater]]
-            [electron.utils :refer [*win mac? linux? logger get-win-from-sender restore-user-fetch-agent]]
+            [electron.utils :refer [*win mac? linux? logger get-win-from-sender restore-user-fetch-agent send-to-renderer]]
             [clojure.string :as string]
             [promesa.core :as p]
             [cljs-bean.core :as bean]
@@ -18,8 +18,9 @@
             [electron.exceptions :as exceptions]
             ["/electron/utils" :as utils]))
 
-(defonce LSP_SCHEME "lsp")
-(defonce LSP_PROTOCOL (str LSP_SCHEME "://"))
+(defonce LSP_SCHEME "logseq")
+(defonce FILE_LSP_SCHEME "lsp")
+(defonce LSP_PROTOCOL (str FILE_LSP_SCHEME "://"))
 (defonce PLUGIN_URL (str LSP_PROTOCOL "logseq.io/"))
 (defonce STATIC_URL (str LSP_PROTOCOL "logseq.com/"))
 (defonce PLUGINS_ROOT (.join path (.homedir os) ".logseq/plugins"))
@@ -38,32 +39,44 @@
                    :logger logger
                    :win    win})))
 
-(defn setup-interceptor! []
-  (.registerFileProtocol
-    protocol "assets"
-    (fn [^js request callback]
-      (let [url (.-url request)
-            path (string/replace url "assets://" "")
-            path (js/decodeURIComponent path)]
-        (callback #js {:path path}))))
+(defn setup-interceptor! [^js app]
+  (.setAsDefaultProtocolClient app LSP_SCHEME)
 
   (.registerFileProtocol
-    protocol LSP_SCHEME
-    (fn [^js request callback]
-      (let [url (.-url request)
-            url' ^js (js/URL. url)
-            [_ ROOT] (if (string/starts-with? url PLUGIN_URL)
-                         [PLUGIN_URL PLUGINS_ROOT]
-                         [STATIC_URL js/__dirname])
-
-            path' (.-pathname url')
-            path' (js/decodeURIComponent path')
-            path' (.join path ROOT path')]
+   protocol "assets"
+   (fn [^js request callback]
+     (let [url (.-url request)
+           path (string/replace url "assets://" "")
+           path (js/decodeURIComponent path)]
+       (callback #js {:path path}))))
 
-        (callback #js {:path path'}))))
+  (.registerFileProtocol
+   protocol FILE_LSP_SCHEME
+   (fn [^js request callback]
+     (let [url (.-url request)
+           url' ^js (js/URL. url)
+           [_ ROOT] (if (string/starts-with? url PLUGIN_URL)
+                      [PLUGIN_URL PLUGINS_ROOT]
+                      [STATIC_URL js/__dirname])
+
+           path' (.-pathname url')
+           path' (js/decodeURIComponent path')
+           path' (.join path ROOT path')]
+
+       (callback #js {:path path'}))))
+
+  (.on app "open-url"
+       (fn [event url]
+         (.info logger "open-url" (str {:url url
+                                        :event event}))
+
+         (let [parsed-url (js/URL. url)]
+           (when (and (= (str LSP_SCHEME ":") (.-protocol parsed-url))
+                      (= "auth-callback" (.-host parsed-url)))
+             (send-to-renderer "loginCallback" (.get (.-searchParams parsed-url) "code"))))))
 
   #(do
-     (.unregisterProtocol protocol LSP_SCHEME)
+     (.unregisterProtocol protocol FILE_LSP_SCHEME)
      (.unregisterProtocol protocol "assets")))
 
 (defn- handle-export-publish-assets [_event html custom-css-path repo-path asset-filenames output-path]
@@ -170,14 +183,15 @@
     (do
       (search/close!)
       (.quit app))
-    (do
+    (let [privileges {:standard        true
+                      :secure          true
+                      :bypassCSP       true
+                      :supportFetchAPI true}]
       (.registerSchemesAsPrivileged
-       protocol (bean/->js [{:scheme     LSP_SCHEME
-                             :privileges {:standard        true
-                                          :secure          true
-                                          :bypassCSP       true
-                                          :supportFetchAPI true}}]))
-
+        protocol (bean/->js [{:scheme     LSP_SCHEME
+                              :privileges privileges}
+                             {:scheme     FILE_LSP_SCHEME
+                              :privileges privileges}]))
       (.on app "second-instance"
            (fn [_event _commandLine _workingDirectory]
              (when-let [win @*win]
@@ -194,7 +208,7 @@
                                      (.quit app)))
       (.on app "ready"
            (fn []
-             (let [t0 (setup-interceptor!)
+             (let [t0 (setup-interceptor! app)
                    ^js win (win/create-main-window)
                    _ (reset! *win win)]
                (.. logger (info (str "Logseq App(" (.getVersion app) ") Starting... ")))

+ 26 - 0
src/electron/electron/file_sync_rsapi.cljs

@@ -0,0 +1,26 @@
+(ns electron.file-sync-rsapi
+  (:require ["@andelf/rsapi" :as rsapi]))
+
+(defn get-local-files-meta [graph-uuid base-path file-paths]
+  (rsapi/getLocalFilesMeta graph-uuid base-path (clj->js file-paths)))
+
+(defn get-local-all-files-meta [graph-uuid base-path]
+  (rsapi/getLocalAllFilesMeta graph-uuid base-path))
+
+(defn rename-local-file [graph-uuid base-path from to]
+  (rsapi/renameLocalFile graph-uuid base-path from to))
+
+(defn delete-local-files [graph-uuid base-path file-paths]
+  (rsapi/deleteLocalFiles graph-uuid base-path (clj->js file-paths)))
+
+(defn update-local-files [graph-uuid base-path file-paths token]
+  (rsapi/updateLocalFiles graph-uuid base-path (clj->js file-paths) token))
+
+(defn delete-remote-files [graph-uuid base-path file-paths txid token]
+  (rsapi/deleteRemoteFiles graph-uuid base-path (clj->js file-paths) txid token))
+
+(defn update-remote-file [graph-uuid base-path file-path txid token]
+  (rsapi/updateRemoteFile graph-uuid base-path file-path txid token))
+
+(defn update-remote-files [graph-uuid base-path file-paths txid token]
+  (rsapi/updateRemoteFiles graph-uuid base-path (clj->js file-paths) txid token))

+ 32 - 1
src/electron/electron/handler.cljs

@@ -17,7 +17,8 @@
             [electron.search :as search]
             [electron.git :as git]
             [electron.plugin :as plugin]
-            [electron.window :as win]))
+            [electron.window :as win]
+            [electron.file-sync-rsapi :as rsapi]))
 
 (defmulti handle (fn [_window args] (keyword (first args))))
 
@@ -399,13 +400,43 @@
   [^js _win [_ graph]]
   (search/version-changed? graph))
 
+
 (defmethod handle :reloadWindowPage [^js win]
   (when-let [web-content (.-webContents win)]
     (.reload web-content)))
 
+
 (defmethod handle :setHttpsAgent [^js _win [_ opts]]
   (utils/set-fetch-agent opts))
 
+;;;;;;;;;;;;;;;;;;;;;;;
+;; file-sync-rs-apis ;;
+;;;;;;;;;;;;;;;;;;;;;;;
+
+(defmethod handle :get-local-files-meta [_ args]
+  (apply rsapi/get-local-files-meta (rest args)))
+
+(defmethod handle :get-local-all-files-meta [_ args]
+  (apply rsapi/get-local-all-files-meta (rest args)))
+
+(defmethod handle :rename-local-file [_ args]
+  (apply rsapi/rename-local-file (rest args)))
+
+(defmethod handle :delete-local-files [_ args]
+  (apply rsapi/delete-local-files (rest args)))
+
+(defmethod handle :update-local-files [_ args]
+  (apply rsapi/update-local-files (rest args)))
+
+(defmethod handle :delete-remote-files [_ args]
+  (apply rsapi/delete-remote-files (rest args)))
+
+(defmethod handle :update-remote-file [_ args]
+  (apply rsapi/update-remote-file (rest args)))
+
+(defmethod handle :update-remote-files [_ args]
+  (apply rsapi/update-remote-files (rest args)))
+
 (defmethod handle :default [args]
   (println "Error: no ipc handler for: " (bean/->js args)))
 

+ 10 - 4
src/main/electron/listener.cljs

@@ -5,12 +5,14 @@
             [frontend.handler.ui :as ui-handler]
             [cljs-bean.core :as bean]
             [frontend.fs.watcher-handler :as watcher-handler]
+            [frontend.fs.sync :as sync]
             [frontend.db :as db]
             [datascript.core :as d]
             [electron.ipc :as ipc]
             [frontend.ui :as ui]
             [frontend.handler.notification :as notification]
-            [frontend.handler.repo :as repo-handler]))
+            [frontend.handler.repo :as repo-handler]
+            [frontend.handler.user :as user]))
 
 (defn persist-dbs!
   []
@@ -36,7 +38,8 @@
   (js/window.apis.on "file-watcher"
                      (fn [data]
                        (let [{:keys [type payload]} (bean/->clj data)]
-                         (watcher-handler/handle-changed! type payload))))
+                         (watcher-handler/handle-changed! type payload)
+                         (sync/file-watch-handler type payload))))
 
   (js/window.apis.on "notification"
                      (fn [data]
@@ -54,7 +57,6 @@
                      (fn []
                        (state/pub-event! [:modal/set-git-username-and-email])))
 
-
   (js/window.apis.on "getCurrentGraph"
                      (fn []
                        (when-let [graph (state/get-current-repo)]
@@ -90,7 +92,11 @@
                              handlers {:before     before-f
                                        :on-success after-f
                                        :on-error   error-f}]
-                         (repo-handler/persist-db! repo handlers)))))
+                         (repo-handler/persist-db! repo handlers))))
+
+  (js/window.apis.on "loginCallback"
+                     (fn [code]
+                       (user/login-callback code))))
 
 (defn listen!
   []

+ 1 - 1
src/main/frontend/commands.cljs

@@ -247,7 +247,7 @@
 
        ["Upload an asset" [[:editor/click-hidden-file-input :id]] "Upload file types like image, pdf, docx, etc.)"]
 
-       (state/logged?)
+       (state/deprecated-logged?)
        ["Upload an image" [[:editor/click-hidden-file-input :id]]])]
 
     (markdown-headings)

+ 121 - 48
src/main/frontend/components/header.cljs

@@ -1,24 +1,28 @@
 (ns frontend.components.header
-  (:require [frontend.components.export :as export]
+  (:require ["path" :as path]
+            [cljs-bean.core :as bean]
+            [frontend.components.export :as export]
+            [frontend.components.page-menu :as page-menu]
             [frontend.components.plugins :as plugins]
             [frontend.components.repo :as repo]
-            [frontend.components.page-menu :as page-menu]
             [frontend.components.right-sidebar :as sidebar]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
+            [frontend.fs.sync :as fs-sync]
             [frontend.handler :as handler]
+            [frontend.handler.file-sync :as file-sync-handler]
             [frontend.handler.plugin :as plugin-handler]
-            [frontend.handler.user :as user-handler]
             [frontend.handler.route :as route-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.handler.web.nfs :as nfs]
+            [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
-            [cljs-bean.core :as bean]
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
-            [frontend.mobile.util :as mobile-util]))
+            [cljs.core.async :as a]))
 
 (rum/defc home-button []
   (ui/with-shortcut :go/home "left"
@@ -30,25 +34,95 @@
                    (route-handler/go-to-journals!))}
      (ui/icon "home" {:style {:fontSize ui/icon-size}})]))
 
-(rum/defc login
-  [logged?]
-  (when (and (not logged?)
-             (not config/publishing?))
+(rum/defc login < rum/reactive
+  []
+  (let [_ (state/sub :auth/id-token)]
+    (when-not config/publishing?
+      (if (user-handler/logged-in?)
+        [:span.text-sm.font-medium (user-handler/email)]
+
+        [:a.button.text-sm.font-medium.block {:on-click #(js/window.open config/LOGIN-URL)}
+         [:span (t :login)]]))))
+
+(rum/defcs file-sync-remote-graphs <
+  (rum/local nil ::remote-graphs)
+  [state]
+  (let [*remote-graphs (::remote-graphs state)
+        refresh-list-fn #(a/go (reset! *remote-graphs (a/<! (file-sync-handler/list-graphs))))]
+    (when (nil? @*remote-graphs)
+      ;; (println "call list-graphs api")
+      (refresh-list-fn))
+    [:div
+     [:div.flex
+      [:h1.title "Remote Graphs"]
+      [:div
+       {:on-click refresh-list-fn}
+       svg/refresh]]
+     [:p.text-sm "click to delete the selected graph"]
+     [:ul
+      (for [graph @*remote-graphs]
+        [:li.mb-4
+         [:a.font-medium
+          {:on-click #(do (println "delete graph" (:GraphName graph) (:GraphUUID graph))
+                          (file-sync-handler/delete-graph (:GraphUUID graph)))}
+          (:GraphName graph)]])]]))
+
+(rum/defcs file-sync <
+  rum/reactive
+  (rum/local nil ::existed-graphs)
+  [state]
+  (let [_ (state/sub :auth/id-token)
+        sync-state (state/sub :file-sync/sync-state)
+        not-syncing? (or (nil? sync-state) (fs-sync/sync-state--stopped? sync-state))
+        *existed-graphs (::existed-graphs state)
+        _ (rum/react file-sync-handler/refresh-file-sync-component)
+        graph-txid-exists? (file-sync-handler/graph-txid-exists?)
+        uploading-files (:current-local->remote-files sync-state)
+        downloading-files (:current-remote->local-files sync-state)]
+    (when-not config/publishing?
+      (when (user-handler/logged-in?)
+        (when-not (file-sync-handler/graph-txid-exists?)
+          (a/go (reset! *existed-graphs (a/<! (file-sync-handler/list-graphs)))))
+        (ui/dropdown-with-links
+         (fn [{:keys [toggle-fn]}]
+           (if not-syncing?
+             [:a.button
+              {:on-click toggle-fn}
+              (ui/icon "cloud-off" {:style {:fontSize ui/icon-size}})]
+             [:a.button
+              {:on-click toggle-fn}
+              (ui/icon "cloud" {:style {:fontSize ui/icon-size}})]))
+         (cond-> []
+           (not graph-txid-exists?)
+           (concat (->> @*existed-graphs
+                        (filterv #(and (:GraphName %) (:GraphUUID %)))
+                        (mapv (fn [graph]
+                                {:title (:GraphName graph)
+                                 :options {:on-click #(file-sync-handler/switch-graph (:GraphUUID graph))}})))
+                   [{:hr true}
+                    {:title "create graph"
+                     :options {:on-click #(file-sync-handler/create-graph (path/basename (state/get-current-repo)))}}])
+           graph-txid-exists?
+           (concat
+            [{:title "toggle file sync"
+              :options {:on-click #(if not-syncing? (fs-sync/sync-start) (fs-sync/sync-stop))}}
+             {:title "remote graph list"
+              :options {:on-click #(state/set-sub-modal! file-sync-remote-graphs)}}]
+            [{:hr true}]
+            (map (fn [f] {:title f
+                          :icon (ui/icon "arrow-narrow-up")}) uploading-files)
+            (map (fn [f] {:title f
+                          :icon (ui/icon "arrow-narrow-down")}) downloading-files)
+            (when sync-state
+              (map-indexed (fn [i f] (:time f)
+                     {:title [:div {:key i} [:div (:path f)] [:div.opacity-50 (util/time-ago (:time f))]]})
+                   (take 10 (:history sync-state))))))
+
+         (cond-> {}
+           (not graph-txid-exists?) (assoc :links-header [:div.font-medium.text-sm.opacity-60.px-4.pt-2
+                                                          "Switch to:"])))))))
+
 
-    (ui/dropdown-with-links
-     (fn [{:keys [toggle-fn]}]
-       [:a.button.text-sm.font-medium.block {:on-click toggle-fn}
-        [:span (t :login)]])
-     (let [list [{:title (t :login-github)
-                  :url (str config/website "/login/github")}]]
-       (mapv
-        (fn [{:keys [title url]}]
-          {:title title
-           :options
-           {:on-click
-            (fn [_] (set! (.-href js/window.location) url))}})
-        list))
-     nil)))
 
 (rum/defc left-menu-button < rum/reactive
   [{:keys [on-click]}]
@@ -60,8 +134,7 @@
 
 (rum/defc dropdown-menu < rum/reactive
   [{:keys [current-repo t]}]
-  (let [logged? (state/logged?)
-        page-menu (page-menu/page-menu nil)
+  (let [page-menu (page-menu/page-menu nil)
         page-menu-and-hr (when (seq page-menu)
                            (concat page-menu [{:hr true}]))]
     (ui/dropdown-with-links
@@ -101,10 +174,11 @@
                   :title (t :discord-title)
                   :target "_blank"}
         :icon (ui/icon "brand-discord")}
-       (when logged?
-         {:title (t :sign-out)
-          :options {:on-click user-handler/sign-out!}
-          :icon svg/logout-sm})]
+       ;; (when logged?
+       ;;   {:title (t :sign-out)
+       ;;    :options {:on-click user-handler/sign-out!}
+       ;;    :icon svg/logout-sm})
+       ]
       (concat page-menu-and-hr)
       (remove nil?))
      {}
@@ -130,17 +204,17 @@
   [t]
   (let [[downloaded, set-downloaded] (rum/use-state nil)
         _ (rum/use-effect!
-            (fn []
-              (when-let [channel (and (util/electron?) "auto-updater-downloaded")]
-                (let [callback (fn [_ args]
-                                 (js/console.debug "[new-version downloaded] args:" args)
-                                 (let [args (bean/->clj args)]
-                                   (set-downloaded args)
-                                   (state/set-state! :electron/auto-updater-downloaded args))
-                                 nil)]
-                  (js/apis.addListener channel callback)
-                  #(js/apis.removeListener channel callback))))
-            [])]
+           (fn []
+             (when-let [channel (and (util/electron?) "auto-updater-downloaded")]
+               (let [callback (fn [_ args]
+                                (js/console.debug "[new-version downloaded] args:" args)
+                                (let [args (bean/->clj args)]
+                                  (set-downloaded args)
+                                  (state/set-state! :electron/auto-updater-downloaded args))
+                                nil)]
+                 (js/apis.addListener channel callback)
+                 #(js/apis.removeListener channel callback))))
+           [])]
 
     (when downloaded
       [:div.cp__header-tips
@@ -149,8 +223,8 @@
          {:on-click #(handler/quit-and-install-new-version!)}
          (svg/reload 16) [:strong (t :updater/quit-and-install)]]]])))
 
-(rum/defc header < rum/reactive
-  [{:keys [open-fn current-repo logged? me default-home new-block-mode]}]
+(rum/defc ^:large-vars/cleanup-todo header < rum/reactive
+  [{:keys [open-fn current-repo default-home new-block-mode]}]
   (let [repos (->> (state/sub [:me :repos])
                    (remove #(= (:url %) config/local-repo)))
         electron-mac? (and util/mac? (util/electron?))
@@ -187,10 +261,10 @@
            (ui/icon "search" {:style {:fontSize ui/icon-size}})]))]
 
      [:div.r.flex
-      (when (and (not (mobile-util/is-native-platform?))
-                 (not (util/electron?)))
-        (login logged?))
-
+      (when-not file-sync-handler/hiding-login&file-sync
+        (file-sync))
+      (when-not file-sync-handler/hiding-login&file-sync
+        (login))
       (when plugin-handler/lsp-enabled?
         (plugins/hook-ui-items :toolbar))
 
@@ -217,8 +291,7 @@
         [:a.text-sm.font-medium.button {:href (rfe/href :graph)}
          (t :graph)])
 
-      (dropdown-menu {:me           me
-                      :t            t
+      (dropdown-menu {:t            t
                       :current-repo current-repo
                       :default-home default-home})
 

+ 16 - 9
src/main/frontend/components/page_menu.cljs

@@ -14,7 +14,9 @@
             [frontend.handler.plugin :as plugin-handler]
             [frontend.mobile.util :as mobile-util]
             [electron.ipc :as ipc]
-            [frontend.config :as config]))
+            [frontend.config :as config]
+            [frontend.handler.user :as user-handler]
+            [frontend.handler.file-sync :as file-sync-handler]))
 
 (defn- delete-page!
   [page-name]
@@ -68,7 +70,8 @@
           favorited? (contains? (set (map util/page-name-sanity-lc favorites))
                                 page-name)
           developer-mode? (state/sub [:ui/developer-mode?])
-          file-path (when (util/electron?) (page-handler/get-page-file-path))]
+          file-path (when (util/electron?) (page-handler/get-page-file-path))
+          _ (state/sub :auth/id-token)]
       (when (and page (not block?))
         (->>
          [{:title   (if favorited?
@@ -81,13 +84,13 @@
                          (page-handler/favorite-page! page-original-name)))}}
 
           (when-not (mobile-util/is-native-platform?)
-           {:title (t :page/presentation-mode)
-            :options {:on-click (fn []
-                                  (state/sidebar-add-block!
-                                   repo
-                                   (:db/id page)
-                                   :page-presentation
-                                   {:page page}))}})
+            {:title (t :page/presentation-mode)
+             :options {:on-click (fn []
+                                   (state/sidebar-add-block!
+                                    repo
+                                    (:db/id page)
+                                    :page-presentation
+                                    {:page page}))}})
 
           ;; TODO: In the future, we'd like to extract file-related actions
           ;; (such as open-in-finder & open-with-default-app) into a sub-menu of
@@ -123,6 +126,10 @@
              :options {:on-click
                        (fn []
                          (shell/get-file-latest-git-log page 100))}})
+          (when (and (user-handler/logged-in?) (not file-sync-handler/hiding-login&file-sync))
+            (when-let [graph-uuid (file-sync-handler/get-current-graph-uuid)]
+              {:title (t :page/file-sync-versions)
+               :options {:on-click #(file-sync-handler/list-file-versions graph-uuid page)}}))
 
           (when (and (util/electron?) file-path)
             {:title   (t :page/open-backup-directory)

+ 1 - 1
src/main/frontend/components/repo.cljs

@@ -56,7 +56,7 @@
             (ui/button
               (t :open-a-directory)
               :on-click #(page-handler/ls-dir-files! shortcut/refresh!))])
-         (when (and (state/logged?) (not (util/electron?)))
+         (when (and (state/deprecated-logged?) (not (util/electron?)))
            (ui/button
              "Add another git repo"
              :href (rfe/href :repo-add nil {:graph-types "github"})

+ 2 - 32
src/main/frontend/components/settings.cljs

@@ -82,24 +82,6 @@
                (util/stop e))}
             svg/external-link " release channel"]])])]))
 
-(rum/defc delete-account-confirm
-  [close-fn]
-  [:div
-   (ui/admonition
-     :important
-     [:p.text-gray-700 (t :user/delete-account-notice)])
-   [:div.mt-5.sm:mt-4.sm:flex.sm:flex-row-reverse
-    [:span.flex.w-full.rounded-md.sm:ml-3.sm:w-auto
-     [:button.inline-flex.justify-center.w-full.rounded-md.border.border-transparent.px-4.py-2.bg-indigo-600.text-base.leading-6.font-medium.text-white.shadow-sm.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
-      {:type     "button"
-       :on-click user-handler/delete-account!}
-      (t :user/delete-account)]]
-    [:span.mt-3.flex.w-full.rounded-md.sm:mt-0.sm:w-auto
-     [:button.inline-flex.justify-center.w-full.rounded-md.border.border-gray-300.px-4.py-2.bg-white.text-base.leading-6.font-medium.text-gray-700.shadow-sm.hover:text-gray-500.focus:outline-none.focus:border-blue-300.focus:shadow-outline-blue.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
-      {:type     "button"
-       :on-click close-fn}
-      "Cancel"]]]])
-
 (rum/defc outdenting-hint
   []
   [:div.ui__modal-panel
@@ -615,7 +597,7 @@
         developer-mode? (state/sub [:ui/developer-mode?])
         cors-proxy (state/sub [:me :cors_proxy])
         https-agent-opts (state/sub [:electron/user-cfgs :settings/agent])
-        logged? (state/logged?)]
+        logged? (state/deprecated-logged?)]
     [:div.panel-wrap.is-advanced
      (when (and util/mac? (util/electron?)) (app-auto-update-row t))
      (usage-diagnostics-row t instrument-disabled?)
@@ -655,19 +637,7 @@
                 :target "_blank"}
             "https://github.com/isomorphic-git/cors-proxy"]])])
 
-     (when logged?
-       [:div
-        [:hr]
-        [:div.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-center.sm:pt-5
-         [:label.block.text-sm.font-medium.leading-5.opacity-70.text-red-600.dark:text-red-400
-          {:for "delete account"}
-          (t :user/delete-account)]
-         [:div.mt-1.sm:mt-0.sm:col-span-2
-          [:div.max-w-lg.rounded-md.sm:max-w-xs
-           (ui/button (t :user/delete-your-account)
-                      :on-click (fn []
-                                  (ui-handler/toggle-settings-modal!)
-                                  (js/setTimeout #(state/set-modal! delete-account-confirm))))]]]])]))
+     ]))
 
 (rum/defcs settings
   < (rum/local [:general :general] ::active)

+ 3 - 4
src/main/frontend/components/sidebar.cljs

@@ -19,6 +19,7 @@
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.route :as route-handler]
             [frontend.handler.page :as page-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.mixins :as mixins]
             [frontend.modules.shortcut.data-helper :as shortcut-dh]
             [frontend.state :as state]
@@ -438,7 +439,6 @@
                 state)}
   [state route-match main-content]
   (let [{:keys [open-fn]} state
-        me (state/sub :me)
         current-repo (state/sub :git/current-repo)
         granted? (state/sub [:nfs/user-granted? (state/get-current-repo)])
         theme (state/sub :ui/theme)
@@ -451,13 +451,13 @@
         right-sidebar-blocks (state/sub-right-sidebar-blocks)
         route-name (get-in route-match [:data :name])
         global-graph-pages? (= :graph route-name)
-        logged? (:name me)
         db-restoring? (state/sub :db/restoring?)
         indexeddb-support? (state/sub :indexeddb/support?)
         page? (= :page route-name)
         home? (= :home route-name)
         edit? (:editor/editing? @state/state)
-        default-home (get-default-home-if-valid)]
+        default-home (get-default-home-if-valid)
+        logged? (user-handler/logged-in?)]
     (theme/container
      {:t             t
       :theme         theme
@@ -488,7 +488,6 @@
                         :logged?        logged?
                         :page?          page?
                         :route-match    route-match
-                        :me             me
                         :default-home   default-home
                         :new-block-mode new-block-mode})
 

+ 2 - 2
src/main/frontend/components/svg.cljs

@@ -82,8 +82,8 @@
 (def folder (hero-icon "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"))
 (def settings-sm [:svg {:viewBox "0 0 20 20", :fill "currentColor", :height "20", :width "20"}
                   [:path {:fill-rule "evenodd", :d "M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z", :clip-rule "evenodd"}]])
-(def logout-sm [:svg {:viewBox "0 0 20 20", :fill "currentColor", :height "18", :width "18"}
-                [:path {:fill-rule "evenodd", :d "M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z", :clip-rule "evenodd"}]])
+;; (def logout-sm [:svg {:viewBox "0 0 20 20", :fill "currentColor", :height "18", :width "18"}
+;;                 [:path {:fill-rule "evenodd", :d "M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z", :clip-rule "evenodd"}]])
 (def trash-sm [:svg {:viewBox "0 0 20 20", :fill "currentColor", :height "16", :width "16"}
                [:path {:fill-rule "evenodd", :d "M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z", :clip-rule "evenodd"}]])
 (def external-link

+ 4 - 0
src/main/frontend/config.cljs

@@ -17,6 +17,10 @@
 
 (def test? false)
 
+;; TODO: add :closure-defines in shadow-cljs.edn when prod env is ready
+(goog-define LOGIN-URL
+             "https://logseq-test.auth.us-east-2.amazoncognito.com/oauth2/authorize?client_id=4fi79en9aurclkb92e25hmu9ts&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
+
 ;; :TODO: How to do this?
 ;; (defonce desktop? ^boolean goog.DESKTOP)
 

+ 6 - 2
src/main/frontend/core.cljs

@@ -7,9 +7,11 @@
             [frontend.routes :as routes]
             [frontend.spec]
             [frontend.log]
+            [frontend.util.persist-var :as persist-var]
             [reitit.frontend :as rf]
             [reitit.frontend.easy :as rfe]
-            [logseq.api]))
+            [logseq.api]
+            [frontend.fs.sync :as sync]))
 
 (defn set-router!
   []
@@ -42,7 +44,8 @@
   (when-let [node (.getElementById js/document "root")]
     (set-router!)
     (rum/mount (page/current-page) node)
-    (display-welcome-message)))
+    (display-welcome-message)
+    (persist-var/load-vars)))
 
 (defn ^:export init []
   ;; init is called ONCE when the page loads
@@ -62,4 +65,5 @@
   ;; stop is called before any code is reloaded
   ;; this is controlled by :before-load in the config
   (handler/stop!)
+  (sync/sync-stop)
   (js/console.log "stop"))

+ 4 - 1
src/main/frontend/dicts.cljc

@@ -169,6 +169,7 @@
         :page/make-public "Make it public for publishing"
         :page/version-history "Check page history"
         :page/open-backup-directory "Open page backups directory"
+        :page/file-sync-versions "Page versions"
         :page/make-private "Make it private"
         :page/delete "Delete page"
         :page/publish "Publish this page on Logseq"
@@ -1072,6 +1073,7 @@
            :page/make-public "导出 HTML 时发布本页面"
            :page/version-history "查看页面历史记录"
            :page/open-backup-directory "打开页面备份文件夹"
+           :page/file-sync-versions "页面历史"
            :page/make-private "导出 HTML 时取消发布本页面"
            :page/delete "删除本页"
            :page/publish "将本页发布至 Logseq"
@@ -3412,7 +3414,8 @@
         :page/open-with-default-app "Открыть через приложение по умолчанию"
         :page/action-publish "Опубликовать"
         :page/make-public "Сделать доступным для публикации"
-        :page/version-history "Проверить историю страницы"
+        :page/version-history "проверить историю git страницы"
+        :page/file-sync-versions "история страницы"
         :page/make-private "Сделать приватным"
         :page/delete "Удалить страницу"
         :page/publish "Опубликовать эту страницу на Logseq"

+ 1223 - 0
src/main/frontend/fs/sync.cljs

@@ -0,0 +1,1223 @@
+(ns frontend.fs.sync
+  (:require [cljs-http.client :as http]
+            [cljs-time.core :as t]
+            [cljs.core.async :as async :refer [go timeout go-loop offer! poll! chan <! >!]]
+            [cljs.core.async.impl.channels]
+            [cljs.core.async.interop :refer [p->c]]
+            [cljs.spec.alpha :as s]
+            [clojure.set :as set]
+            [clojure.string :as string]
+            [electron.ipc :as ipc]
+            [frontend.config :as config]
+            [frontend.debug :as debug]
+            [frontend.handler.user :as user]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.util.persist-var :as persist-var]
+            [medley.core :refer [dedupe-by]]
+            [rum.core :as rum]))
+
+;;; Commentary
+;; file-sync related local files/dirs:
+;; - logseq/graphs-txid.edn
+;;   this file contains graph-uuid & transaction-id
+;;   graph-uuid: the unique identifier of the graph on the server
+;;   transaction-id: sync progress of local files
+;; - logseq/version-files
+;;   downloaded version-files
+;; files included by `get-ignore-files` will not be synchronized.
+;; files in these `get-monitored-dirs` dirs will be synchronized.
+;;
+;; sync strategy:
+;; - when toggle file-sync on, trigger a local->remote-full-sync first,
+;;   local->remote-full-sync will compare local-files with remote-files (by md5 & size),
+;;   and upload new-added-files to remote server.
+;; - if local->remote sync(normal-sync or full-sync) return :need-sync-remote,
+;;   then trigger a remote->local sync
+;; - if remote->local sync return :need-remote->local-full-sync,
+;;   then we need a remote->local-full-sync,
+;;   which compare local-files with remote-files, sync diff-remote-files to local
+;; - local->remote-full-sync will be triggered after 20min of idle
+;; - every 20s, flush local changes, and sync to remote
+
+;; TODO: use access-token instead of id-token
+;; TODO: currently, renaming a page produce 2 file-watch event: unlink & add,
+;;       we need to a new type event 'rename'
+;; TODO: a remote delete-diff cause local related-file deleted, then trigger a `FileChangeEvent`,
+;;       and re-produce a new same-file-delete diff.
+;;; specs
+(s/def ::state #{::idle
+                 ;; sync local-changed files
+                 ::local->remote
+                 ;; sync remote latest-transactions
+                 ::remote->local
+                 ;; local->remote full sync
+                 ::local->remote-full-sync
+                 ;; exec remote->local, then local->remote
+                 ::remote->local=>local->remote
+                 ;; exec remote->local, then local->remote-full-sync
+                 ::remote->local=>local->remote-full-sync
+                 ;; exec remote->local-full-sync, then local->remote-full-sync
+                 ::remote->local-full-sync=>local->remote-full-sync
+                 ::stop})
+(s/def ::path string?)
+(s/def ::time t/date?)
+(s/def ::current-local->remote-files (s/coll-of ::path :kind set?))
+(s/def ::current-remote->local-files (s/coll-of ::path :kind set?))
+(s/def ::history-item (s/keys :req-un [::path ::time]))
+(s/def ::history (s/coll-of ::history-item :kind seq?))
+(s/def ::sync-state (s/keys :req-un [::state
+                                     ::current-local->remote-files
+                                     ::current-remote->local-files
+                                     ::history]))
+
+;; diff
+(s/def ::TXId pos-int?)
+(s/def ::TXType #{"update_files" "delete_files" "rename_file"})
+(s/def ::TXContent string?)
+(s/def ::diff (s/keys :req-un [::TXId ::TXType ::TXContent]))
+
+(s/def ::succ-map #(= {:succ true} %))
+(s/def ::unknown-map (comp some? :unknown))
+(s/def ::stop-map #(= {:stop true} %))
+(s/def ::need-sync-remote #(= {:need-sync-remote true} %))
+
+(s/def ::sync-local->remote!-result
+  (s/or :succ ::succ-map
+        :need-sync-remote ::need-sync-remote
+        :unknown ::unknown-map))
+
+(s/def ::sync-remote->local!-result
+  (s/or :succ ::succ-map
+        :need-remote->local-full-sync
+        #(= {:need-remote->local-full-sync true} %)
+        :stop ::stop-map
+        :unknown ::unknown-map))
+
+(s/def ::sync-local->remote-all-files!-result
+  (s/or :succ ::succ-map
+        :stop ::stop-map
+        :need-sync-remote ::need-sync-remote
+        :unknown ::unknown-map))
+
+(def ws-addr "wss://og96xf1si7.execute-api.us-east-2.amazonaws.com/production?graphuuid=%s")
+
+(def graphs-txid (persist-var/persist-var nil "graphs-txid"))
+
+(defn- update-graphs-txid! [latest-txid graph-uuid repo]
+  (persist-var/-reset-value! graphs-txid [graph-uuid latest-txid] repo)
+  (persist-var/persist-save graphs-txid))
+
+(defn- ws-stop! [*ws]
+  (swap! *ws (fn [o] (assoc o :stop true)))
+  (.close (:ws @*ws)))
+
+(defn- ws-listen!*
+  [graph-uuid *ws remote-changes-chan]
+  (reset! *ws {:ws (js/WebSocket. (util/format ws-addr graph-uuid)) :stop false})
+  ;; (set! (.-onopen (:ws @*ws)) #(println (util/format "ws opened: graph '%s'" graph-uuid %)))
+  (set! (.-onclose (:ws @*ws)) (fn [_e]
+                                 (when-not (true? (:stop @*ws))
+                                   (go
+                                     (timeout 1000)
+                                     (println "re-connecting graph" graph-uuid)
+                                     (ws-listen!* graph-uuid *ws remote-changes-chan)))))
+  (set! (.-onmessage (:ws @*ws)) (fn [e]
+                                   (let [data (js->clj (js/JSON.parse (.-data e)) :keywordize-keys true)]
+                                     (if-let [v (poll! remote-changes-chan)]
+                                       (let [last-txid (:txid v)
+                                             current-txid (:txid data)]
+                                         (if (> last-txid current-txid)
+                                           (offer! remote-changes-chan v)
+                                           (offer! remote-changes-chan data)))
+                                       (offer! remote-changes-chan data))))))
+
+(defn ws-listen!
+  "return channal which output messages from server"
+  [graph-uuid *ws]
+  (let [remote-changes-chan (chan (async/sliding-buffer 1))]
+    (ws-listen!* graph-uuid *ws remote-changes-chan)
+    remote-changes-chan))
+
+(defn- get-json-body [body]
+  (or (and (map? body) body)
+      (or (string/blank? body) nil)
+      (js->clj (js/JSON.parse body) :keywordize-keys true)))
+
+(defn- get-resp-json-body [resp]
+  (-> resp (:body) (get-json-body)))
+
+(defn- request-once [api-name body token]
+  (go
+    (let [resp (http/post (str "https://api.logseq.com/file-sync/" api-name)
+                          {:oauth-token token
+                           :body (js/JSON.stringify (clj->js body))})]
+      {:resp (<! resp)
+       :api-name api-name
+       :body body})))
+
+(defn- request
+  ([api-name body token refresh-token-fn] (request api-name body token refresh-token-fn 0))
+  ([api-name body token refresh-token-fn retry-count]
+   (go
+     (let [resp (<! (request-once api-name body token))]
+       (if (and
+            (= 401 (get-in resp [:resp :status]))
+            (= "Unauthorized" (:message (get-json-body (get-in resp [:resp :body])))))
+         (do
+           (println "will retry after" (min 60000 (* 1000 retry-count)) "ms")
+           (<! (timeout (min 60000 (* 1000 retry-count))))
+           (let [token (<! (refresh-token-fn))]
+             (<! (request api-name body token refresh-token-fn (inc retry-count)))))
+         (:resp resp))))))
+
+(defn- remove-dir-prefix [dir path]
+  (let [r (string/replace path (js/RegExp. (str "^" dir)) "")]
+    (if (string/starts-with? r "/")
+      (string/replace-first r "/" "")
+      r)))
+
+(defn- remove-user-graph-uuid-prefix
+  "<user-uuid>/<graph-uuid>/path -> path"
+  [path]
+  (let [parts (string/split path "/")]
+    (if (and (< 2 (count parts))
+             (= 36 (count (parts 0)))
+             (= 36 (count (parts 1))))
+      (string/join "/" (drop 2 parts))
+      path)))
+
+(defn- encode-filepath
+  [filepath]
+  (->> (string/split filepath "/")
+       (remove empty?)
+       (map js/encodeURIComponent)
+       (string/join "/")))
+
+(defprotocol IRelativePath
+  (-relative-path [this]))
+
+(defprotocol IStoppable
+  (-stop! [this]))
+(defprotocol IStopped?
+  (-stopped? [this]))
+                                        ;from-path, to-path is relative path
+(deftype FileTxn [from-path to-path updated? deleted? txid]
+  Object
+  (renamed? [_]
+    (not= from-path to-path))
+
+  IRelativePath
+  (-relative-path [_] (remove-user-graph-uuid-prefix to-path))
+
+  IEquiv
+  (-equiv [_ ^FileTxn other]
+    (and (= from-path (.-from-path other))
+         (= to-path (.-to-path other))
+         (= updated? (.-updated? other))
+         (= deleted? (.-deleted? other))))
+  IHash
+  (-hash [_] (hash [from-path to-path updated? deleted?]))
+
+  IComparable
+  (-compare [_ ^FileTxn other]
+    (compare txid (.-txid other)))
+
+  IPrintWithWriter
+  (-pr-writer [coll w _opts]
+    (write-all w "#FileTxn[\"" from-path "\" -> \"" to-path
+               "\" (updated? " updated? ", renamed? " (.renamed? coll) ", deleted? " deleted?
+               ", txid " txid ")]")))
+(defn- diff->filetxns
+  "convert diff(`get-diff`) to `FileTxn`"
+  [{:keys [TXId TXType TXContent]}]
+  (let [update? (= "update_files" TXType)
+        delete? (= "delete_files" TXType)
+        update-or-del-type-xf
+        (comp
+         (remove empty?)
+         (map #(->FileTxn % % update? delete? TXId)))
+        filepaths (map js/decodeURIComponent (string/split-lines TXContent))]
+    (case TXType
+      ("update_files" "delete_files")
+      (sequence update-or-del-type-xf filepaths)
+
+      "rename_file"
+      (list (->FileTxn (first filepaths) (second filepaths) false false TXId)))))
+
+(defn- distinct-update-filetxns-xf
+  "transducer.
+  remove duplicate update&delete `FileTxn`s."
+  [rf]
+  (let [seen-update&delete-filetxns (volatile! #{})]
+    (fn
+      ([] (rf))
+      ([result] (rf result))
+      ([result ^FileTxn filetxn]
+       (if (and
+            (or (.-updated? filetxn) (.deleted? filetxn))
+            (contains? @seen-update&delete-filetxns filetxn))
+         result
+         (do (vswap! seen-update&delete-filetxns conj filetxn)
+             (rf result filetxn)))))))
+
+(defn- remove-deleted-filetxns-xf
+  "transducer.
+  remove update&rename filetxns if they are deleted later(in greater txid filetxn)."
+  [rf]
+  (let [seen-deleted-paths (volatile! #{})]
+    (fn
+      ([] (rf))
+      ([result] (rf result))
+      ([result ^FileTxn filetxn]
+       (let [to-path (.-to-path filetxn)
+             from-path (.-from-path filetxn)]
+         (if (contains? @seen-deleted-paths to-path)
+           (do (when (not= to-path from-path)
+                 (vswap! seen-deleted-paths disj to-path)
+                 (vswap! seen-deleted-paths conj from-path))
+               result)
+           (do (vswap! seen-deleted-paths conj to-path)
+               (rf result filetxn))))))))
+
+(defn- partition-filetxns
+  "return transducer.
+  partition filetxns, at most N update-filetxns in each partition,
+  for delete and rename type, only one filetxn in each partition."
+  [n]
+  (comp
+   (partition-by #(.-updated? ^FileTxn %))
+   (map (fn [ts]
+          (if (some-> (first ts) (.-updated?))
+            (partition-all n ts)
+            (map list ts))))
+   cat))
+
+(defn- diffs->partitioned-filetxns
+  "transducer.
+  1. diff -> `FileTxn` , see also `get-diff`
+  2. distinct redundant update type filetxns
+  3. partition filetxns, each partition contains same type filetxns,
+     for update type, at most N items in each partition
+     for delete & rename type, only 1 item in each partition.
+  4. remove update or rename filetxns if they are deleted in later filetxns.
+  NOTE: this xf should apply on reversed diffs sequence (sort by txid)"
+  [n]
+  (comp
+   (map diff->filetxns)
+   cat
+   distinct-update-filetxns-xf
+   remove-deleted-filetxns-xf
+   (partition-filetxns n)))
+
+(defn- filepath->diff
+  [index {:keys [relative-path user-uuid graph-uuid]}]
+  {:post [(s/valid? ::diff %)]}
+  {:TXId (inc index)
+   :TXType "update_files"
+   :TXContent (string/join "/" [user-uuid graph-uuid relative-path])})
+
+(defn- filepaths->partitioned-filetxns
+  "transducer.
+  1. filepaths -> diff
+  2. diffs->partitioned-filetxns"
+  [n graph-uuid user-uuid]
+  (comp
+   (map (fn [p]
+          {:relative-path p :user-uuid user-uuid :graph-uuid graph-uuid}))
+   (map-indexed filepath->diff)
+   (diffs->partitioned-filetxns n)))
+
+(deftype FileMetadata [size etag path last-modified remote? ^:mutable normalized-path]
+  Object
+  (get-normalized-path [_]
+    (when-not normalized-path
+      (set! normalized-path
+            (cond-> path
+              (string/starts-with? path "/") (string/replace-first "/" "")
+              remote? (remove-user-graph-uuid-prefix))))
+    normalized-path)
+
+  IRelativePath
+  (-relative-path [_] path)
+
+  IEquiv
+  (-equiv [o ^FileMetadata other]
+    (and (= size (.-size other))
+         (= (.get-normalized-path o) (.get-normalized-path other))
+         (= etag (.-etag other))))
+
+  IHash
+  (-hash [_] (hash {:size size :etag etag :path path}))
+
+  IPrintWithWriter
+  (-pr-writer [_ w _opts]
+    (write-all w (str {:size size :etag etag :path path :remote? remote?}))))
+
+(defn- relative-path [o]
+  (cond
+    (implements? IRelativePath o)
+    (-relative-path o)
+
+    (string? o)
+    (remove-user-graph-uuid-prefix o)
+
+    :else
+    (throw (js/Error. (str "unsupport type " (type o))))))
+
+;;; APIs
+;; `RSAPI` call apis through rsapi package, supports operations on files
+
+(defprotocol IRSAPI
+  (get-local-files-meta [this graph-uuid base-path filepaths] "get local files' metadata")
+  (get-local-all-files-meta [this graph-uuid base-path] "get all local files' metadata")
+  (rename-local-file [this graph-uuid base-path from to])
+  (update-local-files [this graph-uuid base-path filepaths] "remote -> local")
+  (delete-local-files [this graph-uuid base-path filepaths])
+  (update-remote-file [this graph-uuid base-path filepath local-txid] "local -> remote, return err or txid")
+  (update-remote-files [this graph-uuid base-path filepaths local-txid] "local -> remote, return err or txid")
+  (delete-remote-files [this graph-uuid base-path filepaths local-txid] "return err or txid"))
+
+(defprotocol IRemoteAPI
+  (get-remote-all-files-meta [this graph-uuid] "get all remote files' metadata")
+  (get-remote-files-meta [this graph-uuid filepaths] "get remote files' metadata")
+  (get-remote-graph [this graph-name-opt graph-uuid-opt] "get graph info by GRAPH-NAME-OPT or GRAPH-UUID-OPT")
+  (get-remote-file-versions [this graph-uuid filepath] "get file's version list")
+  (list-remote-graphs [this] "list all remote graphs")
+  (get-diff [this graph-uuid from-txid] "get diff from FROM-TXID, return [txns, latest-txid, min-txid]")
+  (create-graph [this graph-name] "create graph")
+  (delete-graph [this graph-uuid] "delete graph"))
+
+(defprotocol IToken
+  (get-token [this])
+  (refresh-token [this]))
+
+(declare rsapi)
+(defn- check-files-exists [base-path file-paths]
+  (go
+    (let [cause (ex-cause (<! (get-local-files-meta rsapi "" base-path file-paths)))]
+      (assert (nil? cause) (str cause base-path file-paths)))))
+
+(defn- check-files-not-exists [base-path file-paths]
+  (go
+    (let [cause (ex-cause (<! (get-local-files-meta rsapi "" base-path file-paths)))]
+      (assert (some? cause)))))
+
+(defn- retry-rsapi [f]
+  (go-loop [n 3]
+    (let [r (<! (f))]
+      (if (and (instance? ExceptionInfo r)
+               (string/index-of (str (ex-cause r)) "operation timed out")
+               (> n 0))
+        (do
+          (prn (str "retry(" n ") ..."))
+          (recur (dec n)))
+        r))))
+
+(deftype RSAPI []
+  IToken
+  (get-token [this]
+    (go
+      (or (state/get-auth-id-token)
+          (<! (.refresh-token this)))))
+  (refresh-token [_]
+    (go
+      (<! (user/refresh-id-token&access-token))
+      (state/get-auth-id-token)))
+  IRSAPI
+  (get-local-all-files-meta [_ graph-uuid base-path]
+    (go
+      (let [r (<! (retry-rsapi #(p->c (ipc/ipc "get-local-all-files-meta" graph-uuid base-path))))]
+        (if (instance? ExceptionInfo r)
+          r
+          (->> r
+               js->clj
+               (map (fn [[path metadata]]
+                      (->FileMetadata (get metadata "size") (get metadata "md5") path nil false nil)))
+               set)))))
+  (get-local-files-meta [_ graph-uuid base-path filepaths]
+    (go
+      (let [r (<! (retry-rsapi #(p->c (ipc/ipc "get-local-files-meta" graph-uuid base-path filepaths))))]
+        (if (instance? ExceptionInfo r)
+          r
+          (->> r
+               js->clj
+               (map (fn [[path metadata]]
+                      (->FileMetadata (get metadata "size") (get metadata "md5") path nil false nil))))))))
+  (rename-local-file [_ graph-uuid base-path from to]
+    (retry-rsapi #(p->c (ipc/ipc "rename-local-file" graph-uuid base-path from to))))
+  (update-local-files [this graph-uuid base-path filepaths]
+    (println "update-local-files" graph-uuid base-path filepaths)
+    (go
+      (let [token (<! (get-token this))
+            r (<! (retry-rsapi
+                   #(p->c (ipc/ipc "update-local-files" graph-uuid base-path filepaths token))))]
+        (when (state/developer-mode?) (check-files-exists base-path filepaths))
+        r)))
+
+  (delete-local-files [_ graph-uuid base-path filepaths]
+    (go
+      (let [r (<! (retry-rsapi #(p->c (ipc/ipc "delete-local-files" graph-uuid base-path filepaths))))]
+        (when (state/developer-mode?) (check-files-not-exists base-path filepaths))
+        r)))
+
+  (update-remote-file [this graph-uuid base-path filepath local-txid]
+    (go
+      (let [token (<! (get-token this))]
+        (<! (retry-rsapi
+             #(p->c (ipc/ipc "update-remote-file" graph-uuid base-path filepath local-txid token)))))))
+
+  (update-remote-files [this graph-uuid base-path filepaths local-txid]
+    (go
+      (let [token (<! (get-token this))]
+        (<! (retry-rsapi
+             #(p->c (ipc/ipc "update-remote-files" graph-uuid base-path filepaths local-txid token)))))))
+
+  (delete-remote-files [this graph-uuid base-path filepaths local-txid]
+    (go
+      (let [token (<! (get-token this))]
+        (<!
+         (retry-rsapi
+          #(p->c (ipc/ipc "delete-remote-files" graph-uuid base-path filepaths local-txid token))))))))
+
+(def rsapi (->RSAPI))
+
+(deftype RemoteAPI []
+  Object
+
+  (request [this api-name body]
+    (go
+      (let [resp (<! (request api-name body (<! (get-token this)) #(refresh-token this)))]
+        (if (http/unexceptional-status? (:status resp))
+          (get-resp-json-body resp)
+          (ex-info "request failed"
+                   {:err resp :body (get-resp-json-body resp)})))))
+
+  ;; for test
+  (update-files [this graph-uuid txid files]
+    {:pre [(map? files)
+           (number? txid)]}
+    (.request this "update_files" {:GraphUUID graph-uuid :TXId txid :Files files}))
+
+  IToken
+  (get-token [this]
+    (go
+      (or (state/get-auth-id-token)
+          (<! (refresh-token this)))))
+  (refresh-token [_]
+    (go
+      (<! (user/refresh-id-token&access-token))
+      (state/get-auth-id-token)))
+
+  IRemoteAPI
+  (get-remote-all-files-meta [this graph-uuid]
+    (let [file-meta-list (transient #{})]
+      (go-loop [dir nil continuation-token nil]
+        (let [r (<! (.request this "get_all_files"
+                              (into
+                               {}
+                               (remove (comp nil? second)
+                                       {:GraphUUID graph-uuid :Dir dir :ContinuationToken continuation-token}))))]
+          (if (instance? ExceptionInfo r)
+            r
+            (let [next-dir (:NextDir r)
+                  next-continuation-token (:NextContinuationToken r)
+                  objs (:Objects r)]
+              (apply conj! file-meta-list
+                     (map
+                      #(->FileMetadata (:Size %)
+                                       (:ETag %)
+                                       (remove-user-graph-uuid-prefix (js/decodeURIComponent (:Key %)))
+                                       (:LastModified %)
+                                       true nil)
+                      objs))
+              (if (and (empty? next-dir)
+                       (empty? next-continuation-token))
+                (persistent! file-meta-list) ; finish
+                (recur next-dir next-continuation-token))))))))
+
+  (get-remote-files-meta [this graph-uuid filepaths]
+    {:pre [(coll? filepaths)]}
+    (go
+      (let [encoded-filepaths (map encode-filepath filepaths)
+            r (<! (.request this "get_files_meta" {:GraphUUID graph-uuid :Files encoded-filepaths}))]
+        (if (instance? ExceptionInfo r)
+          r
+          (into #{}
+                (map #(->FileMetadata (:Size %)
+                                      (:ETag %)
+                                      (js/decodeURIComponent (:FilePath %))
+                                      (:LastModified %)
+                                      true nil))
+                (:Files r))))))
+
+  (get-remote-graph [this graph-name-opt graph-uuid-opt]
+    {:pre [(or graph-name-opt graph-uuid-opt)]}
+    (.request this "get_graph" (cond-> {}
+                                 (seq graph-name-opt)
+                                 (assoc :GraphName graph-name-opt)
+                                 (seq graph-uuid-opt)
+                                 (assoc :GraphUUID graph-uuid-opt))))
+  (get-remote-file-versions [this graph-uuid filepath]
+    (.request this "get_file_version_list" {:GraphUUID graph-uuid :File (encode-filepath filepath)}))
+  (list-remote-graphs [this]
+    (.request this "list_graphs"))
+
+  (get-diff [this graph-uuid from-txid]
+    ;; TODO: path in transactions should be relative path(now s3 key, which includes graph-uuid and user-uuid)
+    (go
+      (let [r (<! (.request this "get_diff" {:GraphUUID graph-uuid :FromTXId from-txid}))]
+        (if (instance? ExceptionInfo r)
+          r
+          (-> r
+              :Transactions
+              (as-> txns
+                  (sort-by :TXId txns)
+                [txns
+                 (:TXId (last txns))
+                 (:TXId (first txns))]))))))
+
+  (create-graph [this graph-name]
+    (.request this "create_graph" {:GraphName graph-name}))
+
+  (delete-graph [this graph-uuid]
+    (.request this "delete_graph" {:GraphUUID graph-uuid})))
+
+(def remoteapi (->RemoteAPI))
+
+(defn- apply-filetxns
+  [graph-uuid base-path filetxns]
+  (cond
+    (.renamed? (first filetxns))
+    (let [filetxn (first filetxns)]
+      (assert (= 1 (count filetxns)))
+      (rename-local-file rsapi graph-uuid base-path
+                         (relative-path (.-from-path filetxn))
+                         (relative-path (.-to-path filetxn))))
+
+    (.-updated? (first filetxns))
+    (update-local-files rsapi graph-uuid base-path (map relative-path filetxns))
+
+    (.deleted? (first filetxns))
+    (let [filetxn (first filetxns)]
+      (assert (= 1 (count filetxns)))
+      (go
+        (let [r (<! (delete-local-files rsapi graph-uuid base-path [(relative-path filetxn)]))]
+          (if (and (instance? ExceptionInfo r)
+                   (string/index-of (str (ex-cause r)) "No such file or directory"))
+            true
+            r))))))
+
+(declare sync-state--add-current-local->remote-files
+         sync-state--add-current-remote->local-files
+         sync-state--remove-current-local->remote-files
+         sync-state--remove-current-remote->local-files
+         sync-state--stopped?)
+
+(defn- apply-filetxns-partitions
+  "won't call update-graph-txid! when *txid is nil"
+  [*sync-state graph-uuid base-path filetxns-partitions repo *txid *stopped]
+  (go-loop [filetxns-partitions* filetxns-partitions]
+    (if @*stopped
+      {:stop true}
+      (when (seq filetxns-partitions*)
+        (let [filetxns (first filetxns-partitions*)
+              paths (map relative-path filetxns)
+              _ (swap! *sync-state sync-state--add-current-remote->local-files paths)
+              r (<! (apply-filetxns graph-uuid base-path filetxns))
+              _ (swap! *sync-state sync-state--remove-current-remote->local-files paths)]
+          (if (instance? ExceptionInfo r)
+            r
+            (let [latest-txid (apply max (map #(.-txid ^FileTxn %) filetxns))]
+              (when *txid
+                (reset! *txid latest-txid)
+                (update-graphs-txid! latest-txid graph-uuid repo))
+              (recur (next filetxns-partitions*)))))))))
+
+(defmulti need-sync-remote? (fn [v] (cond
+                                      (= :max v)
+                                      :max
+
+                                      (and (vector? v) (number? (first v)))
+                                      :txid
+
+                                      (instance? ExceptionInfo v)
+                                      :exceptional-response
+
+                                      (instance? cljs.core.async.impl.channels/ManyToManyChannel v)
+                                      :chan)))
+
+(defmethod need-sync-remote? :max [_] true)
+(defmethod need-sync-remote? :txid [[txid remote->local-syncer]]
+  (let [remote-txid txid
+        local-txid (.-txid remote->local-syncer)]
+    (or (nil? local-txid)
+        (> remote-txid local-txid))))
+
+(defmethod need-sync-remote? :exceptional-response [resp]
+  (let [data (ex-data resp)
+        cause (ex-cause resp)]
+    (or
+     (and (= (:error data) :promise-error)
+          (string/index-of (str cause) "txid_to_validate")) ;FIXME: better rsapi err info
+     (= 409 (get-in data [:err :status])))))
+
+(defmethod need-sync-remote? :chan [c]
+  (go (need-sync-remote? (<! c))))
+(defmethod need-sync-remote? :default [_] false)
+
+
+
+
+;; type = "change" | "add" | "unlink"
+
+
+(deftype FileChangeEvent [type dir path stat]
+  IRelativePath
+  (-relative-path [_] (remove-dir-prefix dir path))
+
+  IEquiv
+  (-equiv [_ other]
+    (and (= dir (.-dir other))
+         (= type (.-type other))
+         (= path (.-path other))))
+
+  IPrintWithWriter
+  (-pr-writer [_ w _opts]
+    (write-all w (str {:type type :base-path dir :path path :size (:size stat)}))))
+
+(defn- partition-file-change-events
+  "return transducer.
+  partition `FileChangeEvent`s, at most N file-change-events in each partition.
+  only one type in a partition."
+  [n]
+  (comp
+   (partition-by (fn [^FileChangeEvent e]
+                   (case (.-type e)
+                     ("add" "change") :add-or-change
+                     "unlink"         :unlink)))
+   (map #(partition-all n %))
+   cat))
+
+(def local-changes-chan (chan 100))
+(defn file-watch-handler
+  "file-watcher callback"
+  [type {:keys [dir path _content stat] :as _payload}]
+  (go
+    (when (some-> (state/get-file-sync-state)
+                  sync-state--stopped?
+                  not)
+      (>! local-changes-chan (->FileChangeEvent type dir path stat)))))
+
+;;; remote->local syncer & local->remote syncer
+
+(defprotocol IRemote->LocalSync
+  (stop-remote->local! [this])
+  (sync-remote->local! [this] "return ExceptionInfo when error occurs")
+  (sync-remote->local-all-files! [this] "sync all files, return ExceptionInfo when error occurs"))
+
+(defprotocol ILocal->RemoteSync
+  (get-ignore-files [this] "ignored-files won't be synced to remote")
+  (get-monitored-dirs [this])
+  (stop-local->remote! [this])
+  (ratelimit [this from-chan] "get watched local file-change events from FROM-CHAN,
+  return chan returning events with rate limited")
+  (sync-local->remote! [this es] "es is a sequence of `FileChangeEvent`, all items have same type.")
+  (sync-local->remote-all-files! [this] "compare all local files to remote ones, sync when not equal.
+  if local-txid != remote-txid, return {:need-sync-remote true}"))
+
+(deftype Remote->LocalSyncer [graph-uuid base-path repo *txid *sync-state
+                              ^:mutable local->remote-syncer *stopped]
+  Object
+  (set-local->remote-syncer! [_ s] (set! local->remote-syncer s))
+  (sync-files-remote->local!
+    [_ relative-filepaths latest-txid]
+    (go
+      (if-let [user-uuid (user/user-uuid)]
+        (let [partitioned-filetxns
+              (sequence (filepaths->partitioned-filetxns 10 graph-uuid user-uuid)
+                        relative-filepaths)
+              r
+              (if (empty? (flatten partitioned-filetxns))
+                {:succ true}
+                (<! (apply-filetxns-partitions
+                     *sync-state graph-uuid base-path partitioned-filetxns repo
+                     nil *stopped)))]
+          (cond
+            (instance? ExceptionInfo r)
+            {:unknown r}
+
+            @*stopped
+            {:stop true}
+
+            :else
+            (do (update-graphs-txid! latest-txid graph-uuid repo)
+                (reset! *txid latest-txid)
+                {:succ true})))
+        ;; not found user-uuid
+        {:unknown (ex-info "user-uuid not found" {})})))
+
+  IRemote->LocalSync
+  (stop-remote->local! [_] (vreset! *stopped true))
+  (sync-remote->local! [_]
+    (go
+      (let [r
+            (let [diff-r (<! (get-diff remoteapi graph-uuid @*txid))]
+              (if (instance? ExceptionInfo diff-r)
+                diff-r
+                (let [[diff-txns latest-txid min-txid] diff-r]
+                  (if (> (dec min-txid) @*txid) ;; if min-txid-1 > @*txid, need to remote->local-full-sync
+                    (do (println "min-txid" min-txid "request-txid" @*txid)
+                        {:need-remote->local-full-sync true})
+
+                    (when (pos-int? latest-txid)
+                      (let [partitioned-filetxns (transduce (diffs->partitioned-filetxns 10)
+                                                            (completing (fn [r i] (conj r (reverse i)))) ;reverse
+                                                            '()
+                                                            (reverse diff-txns))]
+                        ;; (prn "partition-filetxns" partitioned-filetxns)
+
+                        ;; TODO: precheck etag
+                        (if (empty? (flatten partitioned-filetxns))
+                          (do (update-graphs-txid! latest-txid graph-uuid repo)
+                              (reset! *txid latest-txid)
+                              {:succ true})
+                          (<! (apply-filetxns-partitions
+                               *sync-state graph-uuid base-path partitioned-filetxns repo *txid *stopped)))))))))]
+        (cond
+          (instance? ExceptionInfo r)
+          {:unknown r}
+
+          @*stopped
+          {:stop true}
+
+          (:need-remote->local-full-sync r)
+          r
+
+          :else
+          {:succ true}))))
+
+  (sync-remote->local-all-files! [this]
+    (go
+      (let [remote-all-files-meta-c (get-remote-all-files-meta remoteapi graph-uuid)
+            local-all-files-meta-c (get-local-all-files-meta rsapi graph-uuid base-path)
+            remote-all-files-meta (<! remote-all-files-meta-c)
+            local-all-files-meta (<! local-all-files-meta-c)
+            diff-remote-files (set/difference remote-all-files-meta local-all-files-meta)
+            latest-txid (:TXId
+                         (<! (get-remote-graph remoteapi nil graph-uuid)))]
+        (println "[full-sync(remote->local)]"
+                 (count diff-remote-files) "files need to sync")
+        (<! (.sync-files-remote->local!
+             this (map -relative-path diff-remote-files)
+             latest-txid))))))
+
+(defn- file-changed?
+  "return true when file changed compared with remote"
+  [graph-uuid file-path-without-base-path base-path]
+  (go
+    (let [remote-meta (first (<! (get-remote-files-meta remoteapi graph-uuid [file-path-without-base-path])))
+          local-meta (first (<! (get-local-files-meta rsapi graph-uuid base-path [file-path-without-base-path])))]
+      (not= remote-meta local-meta))))
+
+(defn- contains-path? [regexps path]
+  (reduce #(when (re-find %2 path) (reduced true)) false regexps))
+
+
+(deftype ^:large-vars/cleanup-todo
+    Local->RemoteSyncer [graph-uuid base-path repo *sync-state
+                         ^:mutable rate *txid ^:mutable remote->local-syncer stop-chan ^:mutable stopped]
+    Object
+    (filter-file-change-events-fn [this]
+      (fn [^FileChangeEvent e] (and (instance? FileChangeEvent e)
+                                    (string/starts-with? (.-dir e) base-path)
+                                    (not (contains-path? (get-ignore-files this) (relative-path e)))
+                                    (contains-path? (get-monitored-dirs this) (relative-path e)))))
+
+    (filtered-chan
+      ;; "check base-path"
+      [this n]
+      (chan n (filter (.filter-file-change-events-fn this))))
+
+    (set-remote->local-syncer! [_ s] (set! remote->local-syncer s))
+
+    ILocal->RemoteSync
+    (get-ignore-files [_] #{#"logseq/graphs-txid.edn$" #"logseq/bak/.*" #"version-files/.*" #"logseq/\.recycle/.*"
+                            #"\.DS_Store$"})
+    (get-monitored-dirs [_] #{#"^assets/" #"^journals/" #"^logseq/" #"^pages/"})
+    (stop-local->remote! [_]
+      (async/close! stop-chan)
+      (set! stopped true))
+
+    (ratelimit [this from-chan]
+      (let [c (.filtered-chan this 10000)
+            filter-e-fn (.filter-file-change-events-fn this)]
+        (go-loop [timeout-c (timeout rate)
+                  tcoll (transient [])]
+          (let [{:keys [timeout ^FileChangeEvent e stop]}
+                (async/alt! timeout-c {:timeout true}
+                            from-chan ([e] {:e e})
+                            stop-chan {:stop true})]
+            (cond
+              stop
+              (async/close! c)
+
+              timeout
+              (do
+                (<! (async/onto-chan! c (distinct (persistent! tcoll)) false))
+                (recur (async/timeout rate) (transient [])))
+
+              (some? e)
+              (do
+                (when (filter-e-fn e)
+                  (if (= "unlink" (.-type e))
+                    (conj! tcoll e)
+                    (if (<! (file-changed? graph-uuid (relative-path e) base-path))
+                      (conj! tcoll e)
+                      (prn "file unchanged" (relative-path e)))))
+                (recur timeout-c tcoll))
+
+              (nil? e)
+              (do
+                (println "close ratelimit chan")
+                (async/close! c)))))
+        c))
+
+
+  (sync-local->remote! [this es]
+    (if (empty? es)
+      (go {:succ true})
+      (let [type (.-type ^FileChangeEvent (first es))
+            ignore-files (get-ignore-files this)
+            es->paths-xf (comp
+                            (map #(relative-path %))
+                            (filter #(not (contains-path? ignore-files %))))
+              paths (sequence es->paths-xf es)]
+          (println "sync-local->remote" paths)
+          (let [r (case type
+                    ("add" "change")
+                    (update-remote-files rsapi graph-uuid base-path paths @*txid)
+
+                    "unlink"
+                    (do
+                      ;; ensure local-file deleted, may return no such file exception, but ignore it.
+                      (delete-local-files rsapi graph-uuid base-path paths)
+                      (delete-remote-files rsapi graph-uuid base-path paths @*txid)))]
+            (go
+              (let [_ (swap! *sync-state sync-state--add-current-local->remote-files paths)
+                    r* (<! r)
+                    _ (swap! *sync-state sync-state--remove-current-local->remote-files paths)]
+                (cond
+                  (need-sync-remote? r*)
+                  {:need-sync-remote true}
+
+                  (number? r*)          ; succ
+                  (do
+                    (println "sync-local->remote! update txid" r*)
+                    ;; persist txid
+                    (update-graphs-txid! r* graph-uuid repo)
+                    (reset! *txid r*)
+                    {:succ true})
+
+                  :else
+                  (do
+                    (println "sync-local->remote unknown:" r*)
+                    {:unknown r*}))))))))
+
+    (sync-local->remote-all-files! [this]
+      (go
+        (let [remote-all-files-meta-c (get-remote-all-files-meta remoteapi graph-uuid)
+              local-all-files-meta-c (get-local-all-files-meta rsapi graph-uuid base-path)
+              remote-all-files-meta (<! remote-all-files-meta-c)
+              local-all-files-meta (<! local-all-files-meta-c)
+              diff-local-files (set/difference local-all-files-meta remote-all-files-meta)
+              ignore-files (get-ignore-files this)
+              monitored-dirs (get-monitored-dirs this)
+              change-events-partitions
+              (sequence
+               (comp
+                ;; convert to FileChangeEvent
+                (map #(->FileChangeEvent "change" base-path (.get-normalized-path ^FileMetadata %) nil))
+                ;; filter ignore-files & monitored-dirs
+                (filter #(let [path (relative-path %)]
+                           (and (not (contains-path? ignore-files path))
+                                (contains-path? monitored-dirs path))))
+                ;; partition FileChangeEvents
+                (partition-file-change-events 10))
+               diff-local-files)]
+          (println "[full-sync(local->remote)]" (count (flatten change-events-partitions)) "files need to sync")
+          (loop [es-partitions change-events-partitions]
+            (if stopped
+              {:stop true}
+              (if (empty? es-partitions)
+              {:succ true}
+              (let [{:keys [succ need-sync-remote unknown] :as r}
+                    (<! (sync-local->remote! this (first es-partitions)))]
+                (s/assert ::sync-local->remote!-result r)
+                (cond
+                  succ
+                  (recur (next es-partitions))
+                    (or need-sync-remote unknown) r)))))))))
+
+
+;;; sync state
+
+
+(defn sync-state
+  "create a new sync-state"
+  []
+  {:post [(s/valid? ::sync-state %)]}
+  {:state ::idle
+   :current-local->remote-files #{}
+   :current-remote->local-files #{}
+   :history '()})
+
+(defn- sync-state--update-state
+  [sync-state next-state]
+  {:pre [(s/valid? ::state next-state)]
+   :post [(s/valid? ::sync-state %)]}
+  (assoc sync-state :state next-state))
+
+(defn sync-state--add-current-remote->local-files
+  [sync-state paths]
+  {:post [(s/valid? ::sync-state %)]}
+  (update sync-state :current-remote->local-files into paths))
+
+(defn sync-state--add-current-local->remote-files
+  [sync-state paths]
+  {:post [(s/valid? ::sync-state %)]}
+  (update sync-state :current-local->remote-files into paths))
+
+(defn- add-history-items
+  [history paths now]
+  (sequence
+   (comp
+    ;; only reserve the latest one of same-path-items
+    (dedupe-by :path)
+    ;; reserve the latest 20 history items
+    (take 20))
+   (into history
+         (map (fn [path] {:path path :time now}) paths))))
+
+(defn sync-state--remove-current-remote->local-files
+  [sync-state paths]
+  {:post [(s/valid? ::sync-state %)]}
+  (let [now (t/now)]
+    (-> sync-state
+        (update :current-remote->local-files set/difference paths)
+        (update :history add-history-items paths now))))
+
+(defn sync-state--remove-current-local->remote-files
+  [sync-state paths]
+  {:post [(s/valid? ::sync-state %)]}
+  (let [now (t/now)]
+    (-> sync-state
+        (update :current-local->remote-files set/difference paths)
+        (update :history add-history-items paths now))))
+
+(defn sync-state--stopped?
+  [sync-state]
+  (= ::stop (:state sync-state)))
+
+
+;;; put all stuff together
+
+
+(deftype ^:large-vars/cleanup-todo
+    SyncManager [graph-uuid base-path *sync-state
+                 ^Local->RemoteSyncer local->remote-syncer ^Remote->LocalSyncer remote->local-syncer
+                 full-sync-chan stop-sync-chan remote->local-sync-chan local->remote-sync-chan
+                 local-changes-chan ^:mutable ratelimit-local-changes-chan
+                 *txid ^:mutable state ^:mutable _remote-change-chan ^:mutable _*ws ^:mutable stopped]
+  Object
+  (schedule [this next-state args]
+    {:pre [(s/valid? ::state next-state)]}
+    (println "[SyncManager" graph-uuid "]" (and state (name state)) "->" (and next-state (name next-state)))
+    (set! state next-state)
+    (swap! *sync-state sync-state--update-state next-state)
+    (go
+      (case state
+        ::idle
+        (<! (.idle this))
+        ::local->remote
+        (<! (.local->remote this args))
+        ::remote->local
+        (<! (.remote->local this nil args))
+        ::local->remote-full-sync
+        (<! (.full-sync this))
+        ::remote->local=>local->remote
+        (<! (.remote->local this ::local->remote args))
+        ::remote->local=>local->remote-full-sync
+        (<! (.remote->local this ::local->remote-full-sync args))
+        ::remote->local-full-sync=>local->remote-full-sync
+        (<! (.remote->local-full-sync this ::local->remote-full-sync))
+        ::stop
+        (-stop! this))))
+
+  (start [this]
+    (set! _*ws (atom nil))
+    (set! _remote-change-chan (ws-listen! graph-uuid _*ws))
+    (set! ratelimit-local-changes-chan (ratelimit local->remote-syncer local-changes-chan))
+    (.schedule this ::idle nil))
+
+  (idle [this]
+    (go
+      (let [{:keys [stop full-sync ;; trigger-remote trigger-local
+                    remote local trigger-full-sync]}
+            (async/alt!
+              stop-sync-chan {:stop true}
+              full-sync-chan {:full-sync true}
+              remote->local-sync-chan {:trigger-remote true}
+              local->remote-sync-chan {:trigger-local true}
+              _remote-change-chan ([v] (println "remote changes:" v) {:remote v})
+              ratelimit-local-changes-chan ([v] (println "local changes:" v) {:local v})
+              (timeout (* 20 60 1000)) {:trigger-full-sync true}
+              :priority true)]
+        (cond
+          stop
+          (<! (.schedule this ::stop nil))
+          (or full-sync trigger-full-sync)
+          (<! (.schedule this ::local->remote-full-sync nil))
+          remote
+          (<! (.schedule this ::remote->local {:remote remote}))
+          local
+          (<! (.schedule this ::local->remote {:local local}))
+          :else
+          (<! (.schedule this :idle nil))))))
+
+  (full-sync [this]
+    (go
+      (let [{:keys [succ need-sync-remote unknown stop] :as r}
+            (<! (sync-local->remote-all-files! local->remote-syncer))]
+        (s/assert ::sync-local->remote-all-files!-result r)
+        (cond
+          succ
+          (.schedule this ::idle nil)
+          need-sync-remote
+          (.schedule this ::remote->local=>local->remote-full-sync nil)
+          stop
+          (.schedule this ::stop nil)
+          unknown
+          (do
+            (debug/pprint "full-sync" unknown)
+            (.schedule this ::idle nil))))))
+
+  (remote->local-full-sync [this next-state]
+    (go
+      (let [{:keys [succ unknown stop]}
+            (<! (sync-remote->local-all-files! remote->local-syncer))]
+        (cond
+          succ
+          (.schedule this next-state nil)
+          stop
+          (.schedule this ::stop nil)
+          unknown
+          (do
+            (debug/pprint "remote->local-full-sync" unknown)
+            (.schedule this ::idle nil))))))
+
+  (remote->local [this next-state {remote-val :remote :as args}]
+    (go
+      (if (some-> remote-val :txid (<= @*txid))
+        (.schedule this ::idle nil)
+        (let [{:keys [succ unknown stop need-remote->local-full-sync] :as r}
+              (<! (sync-remote->local! remote->local-syncer))]
+          (s/assert ::sync-remote->local!-result r)
+          (cond
+            need-remote->local-full-sync
+            (.schedule this ::remote->local-full-sync=>local->remote-full-sync nil)
+            succ
+            (.schedule this (or next-state ::idle) args)
+            stop
+            (.schedule this ::stop nil)
+            unknown
+            (do (prn "remote->local err" unknown)
+                (.schedule this ::idle nil)))))))
+
+  (local->remote [this {^FileChangeEvents local-change :local}]
+    (assert (some? local-change) local-change)
+    (go
+      (let [{:keys [succ need-sync-remote unknown] :as r}
+            (<! (sync-local->remote! local->remote-syncer [local-change]))]
+        (s/assert ::sync-local->remote!-result r)
+        (cond
+          succ
+          (.schedule this ::idle nil)
+
+          need-sync-remote
+          (.schedule this ::remote->local=>local->remote nil)
+
+          unknown
+          (do
+            (debug/pprint "local->remote" unknown)
+            (.schedule this ::idle nil))))))
+  IStoppable
+  (-stop! [_]
+    (when-not stopped
+      (set! stopped true)
+      (ws-stop! _*ws)
+      (offer! stop-sync-chan true)
+      (stop-local->remote! local->remote-syncer)
+      (stop-remote->local! remote->local-syncer)
+      (debug/pprint ["stop sync-manager, graph-uuid" graph-uuid "base-path" base-path])
+      (swap! *sync-state sync-state--update-state ::stop))))
+
+(defn sync-manager [graph-uuid base-path repo txid *sync-state full-sync-chan stop-sync-chan
+                    remote->local-sync-chan local->remote-sync-chan local-changes-chan]
+  (let [*txid (atom txid)
+        local->remote-syncer (->Local->RemoteSyncer graph-uuid
+                                                    base-path
+                                                    repo *sync-state
+                                                    20000
+                                                    *txid nil (chan) false)
+        remote->local-syncer (->Remote->LocalSyncer graph-uuid
+                                                    base-path
+                                                    repo *txid *sync-state nil (volatile! false))]
+    (.set-remote->local-syncer! local->remote-syncer remote->local-syncer)
+    (.set-local->remote-syncer! remote->local-syncer local->remote-syncer)
+    (->SyncManager graph-uuid base-path *sync-state local->remote-syncer remote->local-syncer
+                   full-sync-chan stop-sync-chan
+                   remote->local-sync-chan local->remote-sync-chan local-changes-chan nil *txid nil nil nil false)))
+
+(def full-sync-chan (chan 1))
+(def stop-sync-chan (chan 1))
+(def remote->local-sync-chan (chan))
+(def local->remote-sync-chan (chan))
+
+(defn sync-stop []
+  (when-let [sm (state/get-file-sync-manager)]
+    (println "stopping sync-manager")
+    (-stop! sm)))
+
+(defn sync-start []
+  (let [graph-uuid (first @graphs-txid)
+        txid (second @graphs-txid)
+        *sync-state (atom (sync-state))
+        sm (sync-manager graph-uuid
+                         (config/get-repo-dir (state/get-current-repo)) (state/get-current-repo)
+                         txid *sync-state full-sync-chan stop-sync-chan remote->local-sync-chan local->remote-sync-chan
+                         local-changes-chan)]
+    ;; drain `local-changes-chan`
+    (->> (repeatedly #(poll! local-changes-chan))
+         (take-while identity))
+    (poll! stop-sync-chan)
+    ;; update global state when *sync-state changes
+    (add-watch *sync-state ::update-global-state
+               (fn [_ _ _ n]
+                 (state/set-file-sync-state n)))
+    (.start sm)
+
+    (state/set-file-sync-manager sm)
+
+    (offer! full-sync-chan true)
+
+    ;; watch :network/online?
+    (add-watch (rum/cursor state/state :network/online?) "sync-manage"
+               (fn [_k _r _o n]
+                 (when (false? n)
+                   (sync-stop))))
+    ;; watch :auth/id-token
+    (add-watch (rum/cursor state/state :auth/id-token) "sync-manage"
+               (fn [_k _r _o n]
+                 (when (nil? n)
+                   (sync-stop))))))

+ 5 - 1
src/main/frontend/handler.cljs

@@ -18,6 +18,7 @@
             [frontend.handler.page :as page-handler]
             [frontend.handler.repo :as repo-handler]
             [frontend.handler.ui :as ui-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.extensions.srs :as srs]
             [frontend.mobile.core :as mobile]
             [frontend.mobile.util :as mobile-util]
@@ -28,6 +29,7 @@
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.util :as util]
+            [frontend.util.persist-var :as persist-var]
             [cljs.reader :refer [read-string]]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
@@ -166,7 +168,7 @@
                                nil)))))))
 (defn- get-repos
   []
-  (let [logged? (state/logged?)
+  (let [logged? (state/deprecated-logged?)
         me (state/get-me)]
     (p/let [nfs-dbs (db-persist/get-all-graphs)
             nfs-dbs (map (fn [db]
@@ -238,6 +240,8 @@
       (enable-datalog-console))
     (when (util/electron?)
       (el/listen!))
+    (persist-var/load-vars)
+    (user-handler/refresh-tokens-loop)
     (js/setTimeout instrument! (* 60 1000))))
 
 (defn stop! []

+ 15 - 2
src/main/frontend/handler/events.cljs

@@ -38,7 +38,9 @@
             [frontend.encrypt :as encrypt]
             [promesa.core :as p]
             [frontend.fs :as fs]
-            [clojure.string :as string]))
+            [clojure.string :as string]
+            [frontend.util.persist-var :as persist-var]
+            [frontend.fs.sync :as sync]))
 
 ;; TODO: should we move all events here?
 
@@ -84,6 +86,12 @@
       (route-handler/redirect! {:to :import :query-params {:from "picker"}})
       (route-handler/redirect-to-home!))))
 
+(defn- file-sync-stop-when-switch-graph []
+  (p/do! (persist-var/load-vars)
+         (sync/sync-stop)
+         ;; trigger rerender file-sync-header
+         (state/set-file-sync-state nil)))
+
 (defn- graph-switch [graph]
   (repo-handler/push-if-auto-enabled! (state/get-current-repo))
   (state/set-current-repo! graph)
@@ -95,7 +103,11 @@
   (when-let [dir-name (config/get-repo-dir graph)]
     (fs/watch-dir! dir-name))
   (srs/update-cards-due-count!)
-  (state/pub-event! [:graph/ready graph]))
+  (state/pub-event! [:graph/ready graph])
+
+  (file-sync-stop-when-switch-graph))
+
+
 
 (def persist-db-noti-m
   {:before     #(notification/show!
@@ -116,6 +128,7 @@
     (graph-switch graph)))
 
 (defmethod handle :graph/switch [[_ graph]]
+  (file-sync-stop-when-switch-graph)
   (if (outliner-file/writes-finished?)
     (if (util/electron?)
       (graph-switch-on-persisted graph)

+ 93 - 0
src/main/frontend/handler/file_sync.cljs

@@ -0,0 +1,93 @@
+(ns frontend.handler.file-sync
+  (:require ["path" :as path]
+            [cljs-time.coerce :as tc]
+            [cljs.core.async :as async :refer [go <!]]
+            [clojure.string :as string]
+            [frontend.config :as config]
+            [frontend.db :as db]
+            [frontend.fs.sync :as sync]
+            [frontend.handler.notification :as notification]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.util.persist-var :as persist-var]))
+
+(def hiding-login&file-sync (not config/dev?))
+(def refresh-file-sync-component (atom false))
+
+(defn graph-txid-exists?
+  []
+  (let [[graph-uuid _txid] @sync/graphs-txid]
+    (some? graph-uuid)))
+
+
+(defn create-graph
+  [name]
+  (go
+    (let [r* (<! (sync/create-graph sync/remoteapi name))
+          r (if (instance? ExceptionInfo r*) r* (:GraphUUID r*))]
+      (if (and (not (instance? ExceptionInfo r))
+               (string? r))
+        (do
+          (persist-var/-reset-value! sync/graphs-txid [r 0] (state/get-current-repo))
+          (persist-var/persist-save sync/graphs-txid)
+          (swap! refresh-file-sync-component not))
+        (if (= 404 (get-in (ex-data r) [:err :status]))
+          (notification/show! (str "create graph failed: already existed graph: " name) :warning)
+          (notification/show! (str "create graph failed: " r) :warning))))))
+
+(defn delete-graph
+  [graph-uuid]
+  (sync/sync-stop)
+  (go
+    (let [r (<! (sync/delete-graph sync/remoteapi graph-uuid))]
+      (if (instance? ExceptionInfo r)
+        (notification/show! (str "delete graph failed: " graph-uuid) :warning)
+        (notification/show! (str "graph deleted") :success)))))
+
+(defn list-graphs
+  []
+  (go (:Graphs (<! (sync/list-remote-graphs sync/remoteapi)))))
+
+(defn switch-graph [graph-uuid]
+  (persist-var/-reset-value! sync/graphs-txid [graph-uuid 0] (state/get-current-repo))
+  (persist-var/persist-save sync/graphs-txid)
+  (swap! refresh-file-sync-component not))
+
+(defn- download-version-file [graph-uuid file-uuid version-uuid]
+
+  (go
+    (let [key (path/join "version-files" file-uuid version-uuid)
+          r (<! (sync/update-local-files
+                 sync/rsapi graph-uuid (config/get-repo-dir (state/get-current-repo)) [key]))]
+      (if (instance? ExceptionInfo r)
+        (notification/show! (ex-cause r) :error)
+        (notification/show! [:div
+                             [:div "Downloaded version file at: "]
+                             [:div key]] :success false)))))
+
+(defn list-file-versions [graph-uuid page]
+  (let [file-id (:db/id (:block/file page))]
+    (when-let [path (:file/path (db/entity file-id))]
+      (let [base-path (config/get-repo-dir (state/get-current-repo))
+            path* (string/replace-first path base-path "")]
+        (go
+          (let [version-list (:VersionList
+                              (<! (sync/get-remote-file-versions sync/remoteapi graph-uuid path*)))]
+            (notification/show! [:div
+                                 [:div.font-bold "File history - " path*]
+                                 [:hr.my-2]
+                                 (for [version version-list]
+                                   (let [version-uuid (:VersionUUID version)]
+                                     [:div.my-4 {:key version-uuid}
+                                      [:div
+                                       [:a.text-xs.inline
+                                        {:on-click #(download-version-file graph-uuid
+                                                                           (:FileUUID version)
+                                                                           (:VersionUUID version))}
+                                        version-uuid]
+                                       [:div.opacity-70 (str "Size: " (:Size version))]]
+                                      [:div.opacity-50
+                                       (util/time-ago (tc/from-string (:CreateTime version)))]]))]
+                                :success false)))))))
+
+(defn get-current-graph-uuid [] (first @sync/graphs-txid))

+ 167 - 38
src/main/frontend/handler/user.cljs

@@ -3,11 +3,30 @@
             [frontend.db :as db]
             [frontend.handler.config :as config-handler]
             [frontend.handler.notification :as notification]
-            [frontend.idb :as idb]
             [frontend.state :as state]
             [frontend.util :as util]
-            [lambdaisland.glogi :as log]
-            [promesa.core :as p]))
+            [frontend.debug :as debug]
+            [clojure.string :as string]
+            [cljs-time.core :as t]
+            [cljs-time.coerce :as tc]
+            [cljs-http.client :as http]
+            [cljs.core.async :as async :refer [go go-loop <! timeout]]))
+
+;; (defn- email? [v]
+;;   (and v
+;;        (.isValid (EmailAddress. v))))
+
+;; (defn deprecated-set-email!
+;;   [email]
+;;   (when (email? email)
+;;     (util/post (str config/api "email")
+;;                {:email email}
+;;                (fn [_result]
+;;                  (db/transact! [{:me/email email}])
+;;                  (swap! state/state assoc-in [:me :email] email))
+;;                (fn [_error]
+;;                  (notification/show! "Email already exists!"
+;;                                      :error)))))
 
 (defn set-cors!
   [cors-proxy]
@@ -25,45 +44,155 @@
   (when format
     (config-handler/set-config! :preferred-format format)
     (state/set-preferred-format! format)
-    (when (:name (:me @state/state))
-      (when (state/logged?)
-        (util/post (str config/api "set_preferred_format")
-                   {:preferred_format (name format)}
-                   (fn [_result]
-                     (notification/show! "Format set successfully!" :success))
-                   (fn [_e]))))))
+    ;; (when (:name (:me @state/state))
+    ;;   (when (state/logged?)
+    ;;     (util/post (str config/api "set_preferred_format")
+    ;;                {:preferred_format (name format)}
+    ;;                (fn [_result]
+    ;;                  (notification/show! "Format set successfully!" :success))
+    ;;                (fn [_e]))))
+    ))
 
 (defn set-preferred-workflow!
   [workflow]
   (when workflow
     (config-handler/set-config! :preferred-workflow workflow)
     (state/set-preferred-workflow! workflow)
-    (when (:name (:me @state/state))
-      (util/post (str config/api "set_preferred_workflow")
-                 {:preferred_workflow (name workflow)}
-                 (fn [_result]
-                   (notification/show! "Workflow set successfully!" :success))
-                 (fn [_e])))))
-
-(defn sign-out!
-  ([]
-   (sign-out! true))
-  ([confirm?]
-   (when (or (not confirm?)
-             (js/confirm "Your local notes will be completely removed after signing out. Continue?"))
-     (->
-      (idb/clear-local-storage-and-idb!)
-      (p/catch (fn [e]
-                 (println "sign out error: ")
-                 (js/console.dir e)))
-      (p/finally (fn []
-                   (set! (.-href js/window.location) "/logout")))))))
-
-(defn delete-account!
+    ;; (when (:name (:me @state/state))
+    ;;   (util/post (str config/api "set_preferred_workflow")
+    ;;              {:preferred_workflow (name workflow)}
+    ;;              (fn [_result]
+    ;;                (notification/show! "Workflow set successfully!" :success))
+    ;;              (fn [_e])))
+    ))
+
+;; (defn deprecated-sign-out!
+;;   ([]
+;;    (deprecated-sign-out! true))
+;;   ([confirm?]
+;;    (when (or (not confirm?)
+;;              (js/confirm "Your local notes will be completely removed after signing out. Continue?"))
+;;      (->
+;;       (idb/clear-local-storage-and-idb!)
+;;       (p/catch (fn [e]
+;;                  (println "sign out error: ")
+;;                  (js/console.dir e)))
+;;       (p/finally (fn []
+;;                    (set! (.-href js/window.location) "/logout")))))))
+
+;; (defn deprecated-delete-account!
+;;   []
+;;   (p/let [_ (idb/clear-local-storage-and-idb!)]
+;;     (util/delete (str config/api "account")
+;;                  (fn []
+;;                    (deprecated-sign-out! false))
+;;                  (fn [error]
+;;                    (log/error :user/delete-account-failed error)))))
+
+
+
+;;; userinfo, token, login/logout, ...
+
+(defn- parse-jwt [jwt]
+  (some-> jwt
+          (string/split ".")
+          (second)
+          (js/atob)
+          (js/JSON.parse)
+          (js->clj :keywordize-keys true)))
+
+(defn- expired? [parsed-jwt]
+  (some->
+   (* 1000 (:exp parsed-jwt))
+   (tc/from-long)
+   (t/before? (t/now))))
+
+(defn- almost-expired?
+  "return true when jwt will expire after 1h"
+  [parsed-jwt]
+  (some->
+   (* 1000 (:exp parsed-jwt))
+   (tc/from-long)
+   (t/before? (-> 1 t/hours t/from-now))))
+
+(defn email []
+  (some->
+   (state/get-auth-id-token)
+   (parse-jwt)
+   (:email)))
+
+(defn user-uuid []
+  (some->
+   (state/get-auth-id-token)
+   (parse-jwt)
+   (:sub)))
+
+(defn logged-in? []
+  (boolean
+   (some->
+    (state/get-auth-id-token)
+    (parse-jwt)
+    (expired?)
+    (not))))
+
+(defn- clear-tokens []
+  (state/set-auth-id-token nil)
+  (state/set-auth-access-token nil)
+  (state/set-auth-refresh-token nil))
+
+(defn- set-tokens!
+  ([id-token access-token]
+   (state/set-auth-id-token id-token)
+   (state/set-auth-access-token access-token))
+  ([id-token access-token refresh-token]
+   (state/set-auth-id-token id-token)
+   (state/set-auth-access-token access-token)
+   (state/set-auth-refresh-token refresh-token)))
+
+(defn login-callback [code]
+  (go
+    (let [resp (<! (http/get (str "https://api.logseq.com/auth_callback?code=" code)))]
+      (if (= 200 (:status resp))
+        (-> resp
+              (:body)
+              (js/JSON.parse)
+              (js->clj :keywordize-keys true)
+              (as-> $ (set-tokens! (:id_token $) (:access_token $) (:refresh_token $))))
+        (debug/pprint "login-callback" resp)))))
+
+(defn refresh-id-token&access-token
+  "refresh id-token and access-token, if refresh_token expired, clear all tokens
+   return true if success, else false"
   []
-  (p/let [_ (idb/clear-local-storage-and-idb!)]
-    (util/delete (str config/api "account")
-                 (fn []
-                   (sign-out! false))
-                 (fn [error]
-                   (log/error :user/delete-account-failed error)))))
+  (when-let [refresh-token (state/get-auth-refresh-token)]
+    (go
+      (let [resp (<! (http/get (str "https://api.logseq.com/auth_refresh_token?refresh_token=" refresh-token)))]
+        (if (= 400 (:status resp))
+          ;; invalid refresh_token
+          (do
+            (clear-tokens)
+            false)
+          (do
+            (->
+             resp
+             (as-> $ (and (http/unexceptional-status? (:status $)) $))
+             (:body)
+             (js/JSON.parse)
+             (js->clj :keywordize-keys true)
+             (as-> $ (set-tokens! (:id_token $) (:access_token $))))
+            true))))))
+
+;;; refresh tokens loop
+(def stop-refresh false)
+(defn refresh-tokens-loop []
+  (debug/pprint "start refresh-tokens-loop")
+  (go-loop []
+    (<! (timeout 60000))
+    (when (state/get-auth-refresh-token)
+      (let [id-token (state/get-auth-id-token)]
+        (when (or (nil? id-token)
+                  (-> id-token (parse-jwt) (almost-expired?)))
+          (debug/pprint (str "refresh tokens... " (tc/to-string(t/now))))
+          (refresh-id-token&access-token))))
+    (when-not stop-refresh
+      (recur))))

+ 46 - 4
src/main/frontend/state.cljs

@@ -4,6 +4,7 @@
             [cljs-time.format :as tf]
             [cljs.core.async :as async]
             [clojure.string :as string]
+            [cljs.spec.alpha :as s]
             [dommy.core :as dom]
             [medley.core :as medley]
             [electron.ipc :as ipc]
@@ -204,6 +205,20 @@
      :srs/mode?                             false
 
      :srs/cards-due-count                   nil
+
+     :reactive/query-dbs                    {}
+
+     ;; login, userinfo, token, ...
+     :auth/refresh-token                    nil
+     :auth/access-token                     nil
+     :auth/id-token                         nil
+
+     ;; file-sync
+     :file-sync/sync-manager                nil
+     :file-sync/sync-state-manager          nil
+     :file-sync/sync-state                  nil
+     :file-sync/sync-uploading-files        nil
+     :file-sync/sync-downloading-files      nil
      })))
 
 ;; block uuid -> {content(String) -> ast}
@@ -1046,7 +1061,7 @@
   []
   (:name (get-me)))
 
-(defn logged?
+(defn deprecated-logged?
   "Whether the user has logged in."
   []
   (some? (get-name)))
@@ -1519,9 +1534,6 @@
    (set-selection-blocks! blocks direction)
    (util/select-highlight! blocks)))
 
-(defn add-watch-state [key f]
-  (add-watch state key f))
-
 (defn remove-watch-state [key]
   (remove-watch state key))
 
@@ -1617,6 +1629,7 @@
     (->> (sub :sidebar/blocks)
          (filter #(= (first %) current-repo)))))
 
+
 (defn toggle-collapsed-block!
   [block-id]
   (let [current-repo (get-current-repo)]
@@ -1640,3 +1653,32 @@
   (and (editing?)
        ;; config
        (:custom-query? (last (get-editor-args)))))
+
+(defn set-auth-id-token
+  [id-token]
+  (set-state! :auth/id-token id-token))
+
+(defn set-auth-refresh-token
+  [refresh-token]
+  (set-state! :auth/refresh-token refresh-token))
+
+(defn set-auth-access-token
+  [access-token]
+  (set-state! :auth/access-token access-token))
+
+(defn get-auth-id-token []
+  (:auth/id-token @state))
+
+(defn get-auth-refresh-token []
+  (:auth/refresh-token @state))
+
+(defn set-file-sync-manager [v]
+  (set-state! :file-sync/sync-manager v))
+(defn set-file-sync-state [v]
+  (when v (s/assert :frontend.fs.sync/sync-state v))
+  (set-state! :file-sync/sync-state v))
+
+(defn get-file-sync-manager []
+  (:file-sync/sync-manager @state))
+(defn get-file-sync-state []
+  (:file-sync/sync-state @state))

+ 21 - 0
src/main/frontend/util.cljc

@@ -1453,3 +1453,24 @@
 #?(:cljs
    (defn atom? [v]
      (instance? Atom v)))
+
+;; https://stackoverflow.com/questions/32511405/how-would-time-ago-function-implementation-look-like-in-clojure
+#?(:cljs
+   (defn time-ago [time]
+     (let [units [{:name "second" :limit 60 :in-second 1}
+                  {:name "minute" :limit 3600 :in-second 60}
+                  {:name "hour" :limit 86400 :in-second 3600}
+                  {:name "day" :limit 604800 :in-second 86400}
+                  {:name "week" :limit 2629743 :in-second 604800}
+                  {:name "month" :limit 31556926 :in-second 2629743}
+                  {:name "year" :limit js/Number.MAX_SAFE_INTEGER :in-second 31556926}]
+           diff (t/in-seconds (t/interval time (t/now)))]
+       (if (< diff 5)
+         "just now"
+         (let [unit (first (drop-while #(or (>= diff (:limit %))
+                                            (not (:limit %)))
+                                       units))]
+           (-> (/ diff (:in-second unit))
+               Math/floor
+               int
+               (#(str % " " (:name unit) (when (> % 1) "s") " ago"))))))))

+ 41 - 22
src/main/frontend/util/persist_var.cljs

@@ -11,32 +11,39 @@
   (config/get-file-path (state/get-current-repo) (str config/app-name "/" location ".edn")))
 
 (defprotocol ILoad
-  (-load [this]))
+  (-load [this])
+  (-loaded? [this]))
 
 (defprotocol ISave
   (-save [this]))
 
+(defprotocol IResetValue
+  (-reset-value! [this new graph]))
+
 (deftype PersistVar [*value location]
+  IResetValue
+  (-reset-value! [_ new graph]
+    (reset! *value (assoc-in @*value [graph :value] new)))
+
   ILoad
   (-load [_]
-    (state/add-watch-state (keyword (str "persist-var/" location))
-                           (fn [_k _r _o n]
-                             (let [repo (state/get-current-repo)]
-                               (when (and
-                                      (not (get-in @*value [repo :loaded?]))
-                                      (get-in n [:nfs/user-granted? repo]))
-                                 (p/let [content (fs/read-file
-                                                  (config/get-repo-dir (state/get-current-repo))
-                                                  (load-path location))]
-                                   (when-let [content (and (some? content)
-                                                           (try (reader/read-string content)
-                                                                (catch js/Error e
-                                                                  (println (util/format "load persist-var failed: %s"  (load-path location)))
-                                                                  (js/console.dir e))))]
-                                     (swap! *value (fn [o]
-                                                     (-> o
-                                                         (assoc-in [repo :loaded?] true)
-                                                         (assoc-in [repo :value] content)))))))))))
+    (let [repo (state/get-current-repo)]
+      (p/let [content (p/catch
+                          (fs/read-file
+                           (config/get-repo-dir (state/get-current-repo))
+                           (load-path location))
+                          (constantly nil))]
+        (when-let [content (and (some? content)
+                                (try (cljs.reader/read-string content)
+                                     (catch js/Error e
+                                       (println (util/format "load persist-var failed: %s"  (load-path location)))
+                                       (js/console.dir e))))]
+          (swap! *value (fn [o]
+                          (-> o
+                              (assoc-in [repo :loaded?] true)
+                              (assoc-in [repo :value] content))))))))
+  (-loaded? [_]
+    (get-in @*value [(state/get-current-repo) :loaded?]))
 
   ISave
   (-save [_]
@@ -51,8 +58,20 @@
     (get-in @*value [(state/get-current-repo) :value]))
 
   IReset
-  (-reset! [_o new-value]
-    (swap! *value (fn [_o] (assoc-in @*value [(state/get-current-repo) :value] new-value)))))
+  (-reset!
+    ;; "Deprecated - use (.reset-value! o) instead."
+    [_ new-value]
+    (swap! *value (fn [_] (assoc-in @*value [(state/get-current-repo) :value] new-value))))
+
+  IPrintWithWriter
+  (-pr-writer [_ w _opts]
+    (write-all w (str "#PersistVar[" @*value ", loc: " location "]"))))
+
+
+(def *all-persist-vars (atom []))
+
+(defn load-vars []
+  (p/all (mapv -load @*all-persist-vars)))
 
 (defn persist-var
   "This var is stored at logseq/LOCATION.edn"
@@ -61,7 +80,7 @@
                                  {:value init-value
                                   :loaded? false}})
                           location)]
-    (-load var)
+    (swap! *all-persist-vars conj var)
     var))
 
 (defn persist-save [v]

+ 30 - 0
static/yarn.lock

@@ -12,6 +12,36 @@
   resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.1.1.tgz#9274ec7460652f9c632c59addf24efb1684ef876"
   integrity sha512-sAP4LldeWNz0lNzmTird3uWfFDWWTeg6V/MsmyyLR9X1idwKBWIgt/ZvinqQldJm3LecKEs1emkbquO6PCiLVQ==
 
+"@andelf/[email protected]":
+  version "0.0.5"
+  resolved "https://registry.npmmirror.com/@andelf/rsapi-darwin-arm64/download/@andelf/rsapi-darwin-arm64-0.0.5.tgz#894724098fcb722088a2971b6e7771b1aaf1a77b"
+  integrity sha512-S+BzDs0a3El4VnULFBRnaWm8oKGlW+oYVTPELop53gbjcDgmoRq+wIYI8MvvkPdtVYRZ6a/e4DfqrtDKDVWOxQ==
+
+"@andelf/[email protected]":
+  version "0.0.5"
+  resolved "https://registry.npmmirror.com/@andelf/rsapi-darwin-x64/download/@andelf/rsapi-darwin-x64-0.0.5.tgz#a520066709b84c3c20b987c0cda2efa1d0fe1468"
+  integrity sha512-4nVKhh48mVbDgI+EYd3o4J1TC1WwzLk/5/pTvt76lwd5+OhzgFDxQnaFeUsxSMkc7UKixWhp5pnBrrwKRhbVRA==
+
+"@andelf/[email protected]":
+  version "0.0.5"
+  resolved "https://registry.npmmirror.com/@andelf/rsapi-linux-x64-gnu/download/@andelf/rsapi-linux-x64-gnu-0.0.5.tgz#e35c976c6cd73cb22c6fd61f0ee943ee475d3774"
+  integrity sha512-ed5FI+8IGgQMho1hVEV2iip7AdvgeRYtgfCPPmHqRi3uAxILJqp+vRltZb1SqFqmPGSm4IJzvIGZCMGNqcLXSg==
+
+"@andelf/[email protected]":
+  version "0.0.5"
+  resolved "https://registry.npmmirror.com/@andelf/rsapi-win32-x64-msvc/download/@andelf/rsapi-win32-x64-msvc-0.0.5.tgz#8506d864596cf8bf1f8f381ed62653e4731436b8"
+  integrity sha512-8JYGrPGOm00xaYb3KmjMjcIb8T8gkjhNOwduWOS81xkLwGFbwYPeDIKCWEOyXUGy7VCQUvQg2UY9H1t08cR+3w==
+
+"@andelf/[email protected]":
+  version "0.0.5"
+  resolved "https://registry.npmmirror.com/@andelf/rsapi/download/@andelf/rsapi-0.0.5.tgz#6d90290c5a8a88d81402aa2a43d30030fceab60a"
+  integrity sha512-kEobsAzceX+8cGKaAYx4hin/Aw7p5ObdbVapBBzRQRfFqdxU3CJADPxo+InpIRCK4+4B9yzfi7cXWSyi/Av0hQ==
+  optionalDependencies:
+    "@andelf/rsapi-darwin-arm64" "0.0.5"
+    "@andelf/rsapi-darwin-x64" "0.0.5"
+    "@andelf/rsapi-linux-x64-gnu" "0.0.5"
+    "@andelf/rsapi-win32-x64-msvc" "0.0.5"
+
 "@develar/schema-utils@~2.1.0":
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/@develar/schema-utils/-/schema-utils-2.1.0.tgz#eceb1695bfbed6f6bb84666d5d3abe5e1fd54e17"