فهرست منبع

feat!: File Sync (#5355)

- file sync for electron/ios/android
- age encryption of both file content and file path
- massive UI enhancement
- corresponding CI tasks

Co-authored-by: llcc <[email protected]>
Co-authored-by: rcmerci <[email protected]>
Co-authored-by: Tienson Qin <[email protected]>
Co-authored-by: Andelf <[email protected]>
Co-authored-by: Gabriel Horner <[email protected]>
Charlie 3 سال پیش
والد
کامیت
01d879c18e
100فایلهای تغییر یافته به همراه4894 افزوده شده و 1355 حذف شده
  1. 3 0
      .carve/ignore
  2. 9 0
      .github/workflows/build-android.yml
  3. 7 0
      .github/workflows/build-desktop-release.yml
  4. 7 0
      android/app/src/main/AndroidManifest.xml
  5. 351 0
      android/app/src/main/java/com/logseq/app/FileSync.java
  6. 0 59
      android/app/src/main/java/com/logseq/app/GraphFileSync.java
  7. 1 0
      android/app/src/main/java/com/logseq/app/MainActivity.java
  8. 0 25
      android/file-sync/src/androidTest/java/com/logseq/file_sync/ExampleInstrumentedTest.java
  9. 1 1
      android/file-sync/src/main/AndroidManifest.xml
  10. 0 14
      android/file-sync/src/main/java/com/logseq/file_sync/FileSync.java
  11. 33 0
      android/file-sync/src/main/java/com/logseq/sync/FileMeta.java
  12. 34 0
      android/file-sync/src/main/java/com/logseq/sync/RSFileSync.java
  13. BIN
      android/file-sync/src/main/jniLibs/arm64-v8a/libfilesync.so
  14. BIN
      android/file-sync/src/main/jniLibs/arm64-v8a/librsapi.so
  15. BIN
      android/file-sync/src/main/jniLibs/armeabi-v7a/libfilesync.so
  16. BIN
      android/file-sync/src/main/jniLibs/armeabi-v7a/librsapi.so
  17. BIN
      android/file-sync/src/main/jniLibs/x86/libfilesync.so
  18. BIN
      android/file-sync/src/main/jniLibs/x86/librsapi.so
  19. BIN
      android/file-sync/src/main/jniLibs/x86_64/libfilesync.so
  20. BIN
      android/file-sync/src/main/jniLibs/x86_64/librsapi.so
  21. 0 17
      android/file-sync/src/test/java/com/logseq/file_sync/ExampleUnitTest.java
  22. 3 1
      bb.edn
  23. 1 1
      deps.edn
  24. 1 0
      deps/graph-parser/.carve/ignore
  25. 18 0
      deps/graph-parser/src/logseq/graph_parser/config.cljs
  26. 4 0
      ios/App/App.xcodeproj/project.pbxproj
  27. 2 2
      ios/App/App/FileContainer.swift
  28. 110 0
      ios/App/App/FileSync/AgeEncryption.swift
  29. 45 0
      ios/App/App/FileSync/Data+ChaChaPoly.swift
  30. 68 68
      ios/App/App/FileSync/Extensions.swift
  31. 4 0
      ios/App/App/FileSync/FileSync.m
  32. 275 56
      ios/App/App/FileSync/FileSync.swift
  33. 64 9
      ios/App/App/FileSync/SyncClient.swift
  34. 31 28
      ios/App/App/FsWatcher.swift
  35. 22 0
      ios/App/LogseqSpecs/AgeEncryption.podspec
  36. 1 0
      ios/App/Podfile
  37. 1 0
      libs/.gitignore
  38. 4 1
      libs/package.json
  39. 2 0
      package.json
  40. 59 0
      resources/css/tabler-extension.css
  41. BIN
      resources/fonts/tabler-icons-extension.woff2
  42. BIN
      resources/img/file-sync-unavailale-nonbacker-dark.png
  43. BIN
      resources/img/file-sync-unavailale-nonbacker-light.png
  44. BIN
      resources/img/file-sync-welcome-backer-dark.png
  45. BIN
      resources/img/file-sync-welcome-backer-light.png
  46. 1 1
      resources/package.json
  47. 87 49
      scripts/src/logseq/tasks/file_sync.clj
  48. 164 0
      scripts/src/logseq/tasks/file_sync_actions.clj
  49. 2 1
      shadow-cljs.edn
  50. 1 1
      src/electron/electron/backup_file.cljs
  51. 20 2
      src/electron/electron/file_sync_rsapi.cljs
  52. 46 0
      src/electron/electron/handler.cljs
  53. 2 1
      src/main/electron/listener.cljs
  54. 69 4
      src/main/frontend/components/block.cljs
  55. 226 52
      src/main/frontend/components/encryption.cljs
  56. 1 1
      src/main/frontend/components/file.cljs
  57. 674 0
      src/main/frontend/components/file_sync.cljs
  58. 477 0
      src/main/frontend/components/file_sync.css
  59. 32 105
      src/main/frontend/components/header.cljs
  60. 2 6
      src/main/frontend/components/header.css
  61. 4 0
      src/main/frontend/components/onboarding/index.css
  62. 99 17
      src/main/frontend/components/onboarding/quick_tour.cljs
  63. 7 2
      src/main/frontend/components/page.cljs
  64. 31 22
      src/main/frontend/components/page_menu.cljs
  65. 153 83
      src/main/frontend/components/repo.cljs
  66. 66 32
      src/main/frontend/components/settings.cljs
  67. 11 9
      src/main/frontend/components/settings.css
  68. 20 34
      src/main/frontend/components/sidebar.cljs
  69. 22 3
      src/main/frontend/components/sidebar.css
  70. 11 6
      src/main/frontend/components/svg.cljs
  71. 6 1
      src/main/frontend/components/theme.cljs
  72. 34 7
      src/main/frontend/config.cljs
  73. 1 1
      src/main/frontend/core.cljs
  74. 5 4
      src/main/frontend/db/conn.cljs
  75. 10 29
      src/main/frontend/dicts.cljc
  76. 26 12
      src/main/frontend/encrypt.cljs
  77. 3 2
      src/main/frontend/extensions/code.cljs
  78. 2 2
      src/main/frontend/extensions/zotero.cljs
  79. 14 0
      src/main/frontend/fs.cljs
  80. 20 5
      src/main/frontend/fs/capacitor_fs.cljs
  81. 1 0
      src/main/frontend/fs/protocol.cljs
  82. 786 281
      src/main/frontend/fs/sync.cljs
  83. 7 33
      src/main/frontend/handler.cljs
  84. 5 1
      src/main/frontend/handler/editor/lifecycle.cljs
  85. 151 28
      src/main/frontend/handler/events.cljs
  86. 113 92
      src/main/frontend/handler/file_sync.cljs
  87. 18 10
      src/main/frontend/handler/page.cljs
  88. 60 1
      src/main/frontend/handler/repo.cljs
  89. 4 0
      src/main/frontend/handler/route.cljs
  90. 85 32
      src/main/frontend/handler/user.cljs
  91. 103 66
      src/main/frontend/handler/web/nfs.cljs
  92. 22 17
      src/main/frontend/mobile/core.cljs
  93. 6 5
      src/main/frontend/mobile/deeplink.cljs
  94. 3 0
      src/main/frontend/mobile/util.cljs
  95. 1 1
      src/main/frontend/modules/file/core.cljs
  96. 11 10
      src/main/frontend/modules/outliner/file.cljs
  97. 1 1
      src/main/frontend/modules/shortcut/before.cljs
  98. 4 0
      src/main/frontend/modules/shortcut/config.cljs
  99. 1 0
      src/main/frontend/modules/shortcut/dicts.cljc
  100. 2 1
      src/main/frontend/spec/storage.cljc

+ 3 - 0
.carve/ignore

@@ -77,3 +77,6 @@ frontend.test.node-test-runner/main
 frontend.test.frontend-node-test-runner/main
 ;; Test runner for nbb
 logseq.graph-parser.nbb-test-runner/run-tests
+;; For debugging
+frontend.fs.sync/debug-print-sync-events-loop
+frontend.fs.sync/stop-debug-print-sync-events-loop

+ 9 - 0
.github/workflows/build-android.yml

@@ -24,6 +24,11 @@ on:
         type: boolean
         required: true
         default: false
+      enable-file-sync-production:
+        description: 'File sync production mode'
+        type: boolean
+        required: true
+        default: false
   workflow_call:
     inputs:
       build-target:
@@ -32,6 +37,9 @@ on:
       enable-file-sync:
         description: 'Build with file sync support'
         type: boolean
+      enable-file-sync-production:
+        description: 'File sync production mode'
+        type: boolean
     secrets:
       ANDROID_KEYSTORE:
         required: true
@@ -104,6 +112,7 @@ jobs:
       - name: Set Build Environment Variables
         run: |
           echo "ENABLE_FILE_SYNC=${{ inputs.enable-file-sync == 'true' || github.event.inputs.enable-file-sync == 'true' }}" >> $GITHUB_ENV
+          echo "ENABLE_FILE_SYNC_PRODUCTION=${{ inputs.enable-file-sync-production == 'true' || github.event.inputs.enable-file-sync-production == 'true' }}" >> $GITHUB_ENV
 
       - name: Compile CLJS - android variant, use es6 instead of es-next
         run: yarn install && yarn release-android-app

+ 7 - 0
.github/workflows/build-desktop-release.yml

@@ -33,6 +33,11 @@ on:
         type: boolean
         required: true
         default: false
+      enable-file-sync-production:
+        description: 'File sync production mode'
+        type: boolean
+        required: true
+        default: false
       enable-plugins:
         description: 'Build with plugin system support'
         type: boolean
@@ -125,6 +130,7 @@ jobs:
         run: |
           echo "ENABLE_FILE_SYNC=${{ github.event.inputs.enable-file-sync }}" >> $GITHUB_ENV
           echo "ENABLE_PLUGINS=${{ github.event.inputs.enable-plugins }}" >> $GITHUB_ENV
+          echo "ENABLE_FILE_SYNC_PRODUCTION=${{ github.event.inputs.enable-file-sync-production }}" >> $GITHUB_ENV
 
       - name: Compile CLJS
         run: yarn install && gulp build && yarn cljs:release-electron
@@ -427,6 +433,7 @@ jobs:
     with:
       build-target: "${{ github.event.inputs.build-target }}"
       enable-file-sync: "${{ github.event.inputs.enable-file-sync == 'true' }}"
+      enable-file-sync-production: "${{ github.event.inputs.enable-file-sync-production == 'true' }}"
     secrets:
       ANDROID_KEYSTORE: "${{ secrets.ANDROID_KEYSTORE }}"
       ANDROID_KEYSTORE_PASSWORD: "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"

+ 7 - 0
android/app/src/main/AndroidManifest.xml

@@ -32,6 +32,13 @@
                 <data android:mimeType="video/*" />
             </intent-filter>
 
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="logseq" android:host="auth-callback" />
+            </intent-filter>
+
         </activity>
 
         <provider

+ 351 - 0
android/app/src/main/java/com/logseq/app/FileSync.java

@@ -0,0 +1,351 @@
+package com.logseq.app;
+
+import android.net.Uri;
+import android.util.Log;
+
+import com.getcapacitor.JSArray;
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.logseq.sync.FileMeta;
+import com.logseq.sync.RSFileSync;
+
+import org.json.JSONException;
+
+import java.util.List;
+
+@CapacitorPlugin(name = "FileSync")
+public class FileSync extends Plugin {
+
+    @Override
+    public void load() {
+        super.load();
+
+        Log.i("FileSync", "Android plugin loaded");
+    }
+
+    @PluginMethod()
+    public void keygen(PluginCall call) {
+        call.setKeepAlive(true);
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                String[] keyPairs = RSFileSync.keygen();
+                JSObject data = new JSObject();
+                data.put("secretKey", keyPairs[0]);
+                data.put("publicKey", keyPairs[1]);
+                call.resolve(data);
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void setKey(PluginCall call) {
+        String secretKey = call.getString("secretKey");
+        String publicKey = call.getString("publicKey");
+        long code = RSFileSync.setKeys(secretKey, publicKey);
+        if (code != -1) {
+            JSObject ret = new JSObject();
+            ret.put("ok", true);
+            call.resolve(ret);
+        } else {
+            call.reject("invalid setKey call");
+        }
+    }
+
+    @PluginMethod()
+    public void setEnv(PluginCall call) {
+        String env = call.getString("env");
+        if (env == null) {
+            call.reject("required parameter: env");
+            return;
+        }
+        this.setKey(call);
+        long code = RSFileSync.setEnvironment(env);
+        if (code != -1) {
+            JSObject ret = new JSObject();
+            ret.put("ok", true);
+            call.resolve(ret);
+        } else {
+            call.reject("invalid setEnv call");
+        }
+    }
+
+    @PluginMethod()
+    public void encryptFnames(PluginCall call) {
+        call.setKeepAlive(true);
+
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                List<String> filePaths = null;
+                try {
+                    filePaths = call.getArray("filePaths").toList();
+                } catch (JSONException e) {
+                    e.printStackTrace();
+                    return;
+                }
+
+                for (int i = 0; i < filePaths.size(); i++) {
+                    String filePath = filePaths.get(i);
+                    filePaths.set(i, Uri.decode(filePath));
+                }
+
+                String[] raw;
+                raw = RSFileSync.encryptFilenames(filePaths);
+                if (raw != null) {
+                    JSObject ret = new JSObject();
+                    ret.put("value", JSArray.from(raw));
+                    call.resolve(ret);
+                }
+
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void decryptFnames(PluginCall call) {
+        call.setKeepAlive(true);
+
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                JSArray filePaths = call.getArray("filePaths");
+                String[] raw;
+                try {
+                    raw = RSFileSync.decryptFilenames(filePaths.toList());
+                    for (int i = 0; i < raw.length; i++) {
+                        raw[i] = Uri.encode(raw[i], "/");
+                    }
+                    if (raw != null) {
+                        JSObject ret = new JSObject();
+                        ret.put("value", JSArray.from(raw));
+                        call.resolve(ret);
+                    }
+                } catch (JSONException e) {
+                    e.printStackTrace();
+                    call.reject("cannot decrypt fnames: " + e.toString());
+                }
+            }
+        };
+        runner.start();
+    }
+
+    //@PluginMethod(returnType = PluginMethod.RETURN_CALLBACK)
+    @PluginMethod()
+    public void getLocalFilesMeta(PluginCall call) throws JSONException {
+        String basePath = call.getString("basePath");
+        List<String> filePaths = call.getArray("filePaths").toList();
+
+
+        call.setKeepAlive(true);
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                for (int i = 0; i < filePaths.size(); i++) {
+                    String filePath = filePaths.get(i);
+                    filePaths.set(i, Uri.decode(filePath));
+                }
+
+                FileMeta[] metas = RSFileSync.getLocalFilesMeta(basePath, filePaths);
+                if (metas == null) {
+                    call.reject(RSFileSync.getLastError());
+                    return;
+                }
+                JSObject dict = new JSObject();
+                for (FileMeta meta : metas) {
+                    if (meta == null) {
+                        continue;
+                    }
+                    Log.i("FileSync", "got meta " + meta.toString());
+                    JSObject item = new JSObject();
+                    item.put("md5", meta.md5);
+                    item.put("size", meta.size);
+                    item.put("encryptedFname", meta.encryptedFilename);
+
+                    item.put("mtime", meta.mtime); // not used for now
+                    dict.put(Uri.encode(meta.filePath, "/"), item);
+                }
+                JSObject ret = new JSObject();
+                ret.put("result", dict);
+                call.resolve(ret);
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void getLocalAllFilesMeta(PluginCall call) {
+        call.setKeepAlive(true);
+
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                String basePath = call.getString("basePath");
+                FileMeta[] metas = RSFileSync.getLocalAllFilesMeta(basePath);
+                if (metas == null) {
+                    call.reject(RSFileSync.getLastError());
+                    return;
+                }
+                JSObject dict = new JSObject();
+                for (FileMeta meta : metas) {
+                    JSObject item = new JSObject();
+                    item.put("md5", meta.md5);
+                    item.put("size", meta.size);
+                    item.put("encryptedFname", meta.encryptedFilename);
+
+                    item.put("mtime", meta.mtime); // not used for now
+                    dict.put(Uri.encode(meta.filePath, "/"), item);
+                }
+                JSObject ret = new JSObject();
+                ret.put("result", dict);
+                call.resolve(ret);
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void deleteLocalFiles(PluginCall call) throws JSONException {
+        String basePath = call.getString("basePath");
+        List<String> filePaths = call.getArray("filePaths").toList();
+        for (int i = 0; i < filePaths.size(); i++) {
+            filePaths.set(i, Uri.decode(filePaths.get(i)));
+        }
+
+        RSFileSync.deleteLocalFiles(basePath, filePaths);
+
+        JSObject ret = new JSObject();
+        ret.put("ok", true);
+        call.resolve(ret);
+    }
+
+    @PluginMethod()
+    public void updateLocalFiles(PluginCall call) throws JSONException {
+        String basePath = call.getString("basePath");
+        List<String> filePaths = call.getArray("filePaths").toList();
+        String graphUUID = call.getString("graphUUID");
+        String token = call.getString("token");
+
+        for (int i = 0; i < filePaths.size(); i++) {
+            filePaths.set(i, Uri.decode(filePaths.get(i)));
+        }
+
+        call.setKeepAlive(true);
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                long code = RSFileSync.updateLocalFiles(basePath, filePaths, graphUUID, token);
+                if (code != -1) {
+                    JSObject ret = new JSObject();
+                    ret.put("ok", true);
+                    call.resolve(ret);
+                } else {
+                    call.reject(RSFileSync.getLastError());
+                }
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void updateLocalVersionFiles(PluginCall call) throws JSONException {
+        String basePath = call.getString("basePath");
+        List<String> filePaths = call.getArray("filePaths").toList();
+        String graphUUID = call.getString("graphUUID");
+        String token = call.getString("token");
+
+        for (int i = 0; i < filePaths.size(); i++) {
+            filePaths.set(i, Uri.decode(filePaths.get(i)));
+        }
+
+        call.setKeepAlive(true);
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                long code = RSFileSync.updateLocalVersionFiles(basePath, filePaths, graphUUID, token);
+                if (code != -1) {
+                    JSObject ret = new JSObject();
+                    ret.put("ok", true);
+                    call.resolve(ret);
+                } else {
+                    call.reject(RSFileSync.getLastError());
+                }
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void deleteRemoteFiles(PluginCall call) throws JSONException {
+        List<String> filePaths = call.getArray("filePaths").toList();
+        String graphUUID = call.getString("graphUUID");
+        String token = call.getString("token");
+        long txid = call.getInt("txid").longValue();
+
+        for (int i = 0; i < filePaths.size(); i++) {
+            filePaths.set(i, Uri.decode(filePaths.get(i)));
+        }
+
+        call.setKeepAlive(true);
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                long code = RSFileSync.deleteRemoteFiles(filePaths, graphUUID, token, txid);
+                if (code != -1) {
+                    JSObject ret = new JSObject();
+                    ret.put("ok", true);
+                    ret.put("txid", code);
+                    call.resolve(ret);
+                } else {
+                    call.reject(RSFileSync.getLastError());
+                }
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void updateRemoteFiles(PluginCall call) throws JSONException {
+        String basePath = call.getString("basePath");
+        List<String> filePaths = call.getArray("filePaths").toList();
+        String graphUUID = call.getString("graphUUID");
+        String token = call.getString("token");
+        long txid = call.getInt("txid").longValue();
+        // NOTE: fnameEncryption is ignored. since it's always on.
+
+        for (int i = 0; i < filePaths.size(); i++) {
+            filePaths.set(i, Uri.decode(filePaths.get(i)));
+        }
+
+        Thread runner = new Thread() {
+            @Override
+            public void run() {
+                long code = RSFileSync.updateRemoteFiles(basePath, filePaths, graphUUID, token, txid);
+                if (code != -1) {
+                    JSObject ret = new JSObject();
+                    ret.put("ok", true);
+                    ret.put("txid", code);
+                    call.resolve(ret);
+                } else {
+                    call.reject(RSFileSync.getLastError());
+                }
+            }
+        };
+        runner.start();
+    }
+
+    @PluginMethod()
+    public void ageEncryptWithPassphrase(PluginCall call) {
+        call.reject("unimplemented");
+    }
+
+    @PluginMethod()
+    public void ageDecryptWithPassphrase(PluginCall call) {
+        call.reject("unimplemented");
+    }
+}

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

@@ -1,59 +0,0 @@
-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/app/src/main/java/com/logseq/app/MainActivity.java

@@ -15,6 +15,7 @@ public class MainActivity extends BridgeActivity {
         super.onCreate(savedInstanceState);
         registerPlugin(FolderPicker.class);
         registerPlugin(FsWatcher.class);
+        registerPlugin(FileSync.class);
 
         new Timer().schedule(new TimerTask() {
             @Override

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

@@ -1,25 +0,0 @@
-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());
-    }
-}

+ 1 - 1
android/file-sync/src/main/AndroidManifest.xml

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

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

@@ -1,14 +0,0 @@
-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();
-}

+ 33 - 0
android/file-sync/src/main/java/com/logseq/sync/FileMeta.java

@@ -0,0 +1,33 @@
+package com.logseq.sync;
+
+public class FileMeta {
+    public String filePath;
+    public long size;
+    public long mtime;
+    public String md5;
+    public String encryptedFilename;
+
+    public FileMeta(String filePath, long size, long mtime, String md5) {
+        this.filePath = filePath;
+        this.size = size;
+        this.mtime = mtime;
+        this.md5 = md5;
+        this.encryptedFilename = encryptedFilename;
+    }
+
+    public FileMeta(long size, long mtime, String md5) {
+        this.size = size;
+        this.mtime = mtime;
+        this.md5 = md5;
+        this.encryptedFilename = null;
+    }
+
+    public String toString() {
+        return "FileMeta{" +
+                "size=" + size +
+                ", mtime=" + mtime +
+                ", md5='" + md5 + '\'' +
+                ", encryptedFilename='" + encryptedFilename + '\'' +
+                '}';
+    }
+}

+ 34 - 0
android/file-sync/src/main/java/com/logseq/sync/RSFileSync.java

@@ -0,0 +1,34 @@
+package com.logseq.sync;
+
+import java.util.List;
+
+public class RSFileSync {
+    static {
+        System.loadLibrary("rsapi");
+    }
+
+    public static native String getLastError();
+
+    public static native String[] keygen();
+
+    public static native long setEnvironment(String env);
+    public static native long setKeys(String secretKey, String publicKey);
+
+    public static native String[] encryptFilenames(List<String> filenames);
+    public static native String[] decryptFilenames(List<String> encryptedFilenames);
+
+    public static native FileMeta[] getLocalFilesMeta(String basePath, List<String> filePaths);
+    public static native FileMeta[] getLocalAllFilesMeta(String basePath);
+
+    public static native long renameLocalFile(String basePath, String oldPath, String newPath);
+
+    public static native void deleteLocalFiles(String basePath, List<String> filePaths);
+
+    public static native long updateLocalFiles(String basePath, List<String> filePaths, String graphUUID, String token);
+
+    public static native long updateLocalVersionFiles(String basePath, List<String> filePaths, String graphUUID, String token);
+
+    public static native long deleteRemoteFiles(List<String> filePaths, String graphUUID, String token, long txid);
+
+    public static native long updateRemoteFiles(String basePath, List<String> filePaths, String graphUUID, String token, long txid);
+}

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


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


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


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


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


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


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


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


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

@@ -1,17 +0,0 @@
-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);
-    }
-}

+ 3 - 1
bb.edn

@@ -8,7 +8,9 @@
   {:git/url "https://github.com/logseq/bb-tasks"
    :git/sha "abb32ccd26405d56fd28a29d56f3cb902b8c4334"}
   logseq/graph-parser
-  {:local/root "deps/graph-parser"}}
+  {:local/root "deps/graph-parser"}
+  org.clj-commons/digest
+  {:mvn/version "1.4.100"}}
  :pods
  {clj-kondo/clj-kondo {:version "2022.02.09"}
   org.babashka/fswatcher {:version "0.0.3"}}

+ 1 - 1
deps.edn

@@ -35,7 +35,7 @@
  :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
                   :extra-deps  {org.clojure/clojurescript        {:mvn/version "1.11.54"}
                                 org.clojure/tools.namespace      {:mvn/version "0.2.11"}
-                                cider/cider-nrepl                {:mvn/version "0.26.0"}
+                                cider/cider-nrepl                {:mvn/version "0.28.4"}
                                 org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"}}
                   :main-opts   ["-m" "shadow.cljs.devtools.cli"]}
 

+ 1 - 0
deps/graph-parser/.carve/ignore

@@ -19,6 +19,7 @@ logseq.graph-parser.util.page-ref/->page-ref
 ;; API
 logseq.graph-parser.util.page-ref/get-page-name!
 ;; API
+logseq.graph-parser.config/remove-asset-protocol
 logseq.graph-parser.property/->block-content
 ;; API
 logseq.graph-parser.property/property-value-from-content

+ 18 - 0
deps/graph-parser/src/logseq/graph_parser/config.cljs

@@ -7,6 +7,10 @@
   "Copy of frontend.config/app-name. Too small to couple to main app"
   "logseq")
 
+(defonce asset-protocol "assets://")
+(defonce capacitor-protocol "capacitor://")
+(defonce capacitor-protocol-with-prefix (str capacitor-protocol "localhost/_capacitor_file_"))
+
 (defonce local-assets-dir "assets")
 
 (defn local-asset?
@@ -14,6 +18,20 @@
   (and (string? s)
        (re-find (re-pattern (str "^[./]*" local-assets-dir)) s)))
 
+(defn local-protocol-asset?
+  [s]
+  (when (string? s)
+    (or (string/starts-with? s asset-protocol)
+        (string/starts-with? s capacitor-protocol))))
+
+(defn remove-asset-protocol
+  [s]
+  (if (local-protocol-asset? s)
+    (-> s
+        (string/replace-first asset-protocol "")
+        (string/replace-first capacitor-protocol-with-prefix "file://"))
+    s))
+
 (defonce default-draw-directory "draws")
 
 (defn draw?

+ 4 - 0
ios/App/App.xcodeproj/project.pbxproj

@@ -32,6 +32,7 @@
 		FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
 		FE8C946B27FD762700C8017B /* FileSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946927FD762700C8017B /* FileSync.swift */; };
 		FE8C946C27FD762700C8017B /* FileSync.m in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946A27FD762700C8017B /* FileSync.m */; };
+		FEE688A428448F8C0019510E /* AgeEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE688A328448F8C0019510E /* AgeEncryption.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -93,6 +94,7 @@
 		FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = "<group>"; };
 		FE8C946927FD762700C8017B /* FileSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSync.swift; sourceTree = "<group>"; };
 		FE8C946A27FD762700C8017B /* FileSync.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileSync.m; sourceTree = "<group>"; };
+		FEE688A328448F8C0019510E /* AgeEncryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeEncryption.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -199,6 +201,7 @@
 				FE443F1D27FF54AA007ECE65 /* Payload.swift */,
 				FE443F1B27FF5420007ECE65 /* Extensions.swift */,
 				FE8C946A27FD762700C8017B /* FileSync.m */,
+				FEE688A328448F8C0019510E /* AgeEncryption.swift */,
 			);
 			path = FileSync;
 			sourceTree = "<group>";
@@ -355,6 +358,7 @@
 				504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
 				FE443F1E27FF54AA007ECE65 /* Payload.swift in Sources */,
 				5FF8632C283B5BFD0047731B /* Utils.m in Sources */,
+				FEE688A428448F8C0019510E /* AgeEncryption.swift in Sources */,
 				FE8C946B27FD762700C8017B /* FileSync.swift in Sources */,
 				FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */,
 				5FF8632A283B5ADB0047731B /* Utils.swift in Sources */,

+ 2 - 2
ios/App/App/FileContainer.swift

@@ -29,8 +29,8 @@ public class FileContainer: CAPPlugin, UIDocumentPickerDelegate {
             validateDocuments(at: self.localContainerUrl!)
         }
         
-        call.resolve(["path": [self.iCloudContainerUrl?.path as Any,
-                               self.localContainerUrl?.path as Any]])
+        call.resolve(["path": [self.iCloudContainerUrl?.absoluteString as Any,
+                               self.localContainerUrl?.absoluteString as Any]])
     }
     
     func validateDocuments(at url: URL) {

+ 110 - 0
ios/App/App/FileSync/AgeEncryption.swift

@@ -0,0 +1,110 @@
+//
+//  AgeEncryption.swift
+//  Logseq
+//
+//  Created by Mono Wang on 5/30/R4.
+//
+
+import Foundation
+import AgeEncryption
+
+public enum AgeEncryption {
+    public static func keygen() -> (String, String) {
+        let cSecretKey = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+        let cPublicKey = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+        
+        rust_age_encryption_keygen(cSecretKey, cPublicKey);
+        let secretKey = String(cString: cSecretKey.pointee!)
+        let publicKey = String(cString: cPublicKey.pointee!)
+        
+        rust_age_encryption_free_str(cSecretKey.pointee!)
+        rust_age_encryption_free_str(cPublicKey.pointee!)
+        cSecretKey.deallocate()
+        cPublicKey.deallocate()
+
+        return (secretKey, publicKey)
+    }
+    
+    public static func toRawX25519Key(_ secretKey: String) -> Data? {
+        let cOutput = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+
+        let ret = rust_age_encryption_to_raw_x25519_key(secretKey.cString(using: .utf8), cOutput)
+        if ret >= 0 {
+            let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: 32)
+            let rawKey = Data.init(buffer: cOutputBuf)
+            rust_age_encryption_free_vec(cOutput.pointee, ret)
+            cOutput.deallocate()
+            return rawKey
+        } else {
+            return nil
+        }
+    }
+    
+    public static func encryptWithPassphrase(_ plaintext: Data, _ passphrase: String, armor: Bool) -> Data? {
+        plaintext.withUnsafeBytes { (cPlaintext) in
+            let cOutput = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+
+            let ret = rust_age_encrypt_with_user_passphrase(passphrase.cString(using: .utf8), cPlaintext.bindMemory(to: CChar.self).baseAddress, Int32(plaintext.count), armor ? 1 : 0, cOutput)
+            if ret > 0 {
+                let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret))
+                let ciphertext = Data.init(buffer: cOutputBuf)
+                rust_age_encryption_free_vec(cOutput.pointee, ret)
+                cOutput.deallocate()
+                return ciphertext
+            } else {
+                return nil
+            }
+        }
+    }
+
+    public static func decryptWithPassphrase(_ ciphertext: Data, _ passphrase: String) -> Data? {
+        ciphertext.withUnsafeBytes { (cCiphertext) in
+            let cOutput = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+
+            let ret = rust_age_decrypt_with_user_passphrase(passphrase.cString(using: .utf8), cCiphertext.bindMemory(to: CChar.self).baseAddress, Int32(ciphertext.count), cOutput)
+            if ret > 0 {
+                let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret))
+                let plaintext = Data.init(buffer: cOutputBuf)
+                rust_age_encryption_free_vec(cOutput.pointee, ret)
+                cOutput.deallocate()
+                return plaintext
+            } else {
+                return nil
+            }
+        }
+    }
+    
+    public static func encryptWithX25519(_ plaintext: Data, _ publicKey: String, armor: Bool) -> Data? {
+        plaintext.withUnsafeBytes { (cPlaintext) in
+            let cOutput = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+            
+            let ret = rust_age_encrypt_with_x25519(publicKey.cString(using: .utf8), cPlaintext.bindMemory(to: CChar.self).baseAddress, Int32(plaintext.count), armor ? 1 : 0, cOutput)
+            if ret > 0 {
+                let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret))
+                let ciphertext = Data.init(buffer: cOutputBuf)
+                rust_age_encryption_free_vec(cOutput.pointee, ret)
+                cOutput.deallocate()
+                return ciphertext
+            } else {
+                return nil
+            }
+        }
+    }
+    
+    public static func decryptWithX25519(_ ciphertext: Data, _ secretKey: String) -> Data? {
+        ciphertext.withUnsafeBytes { (cCiphertext) in
+            let cOutput = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
+
+            let ret = rust_age_decrypt_with_x25519(secretKey.cString(using: .utf8), cCiphertext.bindMemory(to: CChar.self).baseAddress, Int32(ciphertext.count), cOutput)
+            if ret >= 0 {
+                let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret))
+                let plaintext = Data.init(buffer: cOutputBuf)
+                rust_age_encryption_free_vec(cOutput.pointee, ret)
+                cOutput.deallocate()
+                return plaintext
+            } else {
+                return nil
+            }
+        }
+    }
+}

+ 45 - 0
ios/App/App/FileSync/Data+ChaChaPoly.swift

@@ -0,0 +1,45 @@
+//
+//  Data+ChaChaPoly.swift
+//  Logseq
+//
+//  Created by Mono Wang on 5/20/R4.
+//
+
+import Foundation
+import CryptoKit
+
+extension Data {
+    /**
+     Encrypts current data using ChaChaPoly cipher.
+     */
+    public func sealChaChaPoly(with passphrase: String) -> Data? {
+        guard let symmetricKey = try? SymmetricKey(passwordString: passphrase) else {
+            return nil
+        }
+
+        let nonce = try? ChaChaPoly.Nonce(data: Data(repeating: 0x00, count: 12))
+        if let encrypted = try? ChaChaPoly.seal(self, using: symmetricKey, nonce: nonce!).combined {
+            var ret = Data(hexEncoded: "4c530031")
+            ret?.append(encrypted)
+            return ret
+        }
+        return nil
+    }
+
+    /**
+     Decrypts current combined ChaChaPoly Selead box data (nonce || ciphertext || tag) using ChaChaPoly cipher.
+     */
+    public func openChaChaPoly(with passphrase: String) -> Data? {
+        if self.count <= 4 {
+            return nil
+        }
+        guard let symmetricKey = try? SymmetricKey(passwordString: passphrase) else {
+            return nil
+        }
+        guard let chaChaPolySealedBox = try? ChaChaPoly.SealedBox(combined: self[4...]) else {
+            return nil
+        }
+
+        return try? ChaChaPoly.open(chaChaPolySealedBox, using: symmetricKey)
+    }
+}

+ 68 - 68
ios/App/App/FileSync/Extensions.swift

@@ -50,25 +50,6 @@ extension Array where Element == UInt8 {
   }
 }
 
-@available(iOS 13.0, *)
-extension SymmetricKey {
-    public init(passwordString keyString: String) throws {
-        let size = SymmetricKeySize.bits256
-        guard var keyData = keyString.data(using: .utf8) else {
-            print("Could not create raw Data from String.")
-            throw CryptoKitError.incorrectParameterSize
-        }
-            
-        let keySizeBytes = size.bitCount / 8
-        keyData = keyData.subdata(in: 0..<keySizeBytes)
-        guard keyData.count >= keySizeBytes else { throw CryptoKitError.incorrectKeySize }
-        
-        print("debug key \(keyData) \(keyData.hexDescription)")
-        
-        self.init(data: keyData)
-    }
-}
-
 extension Data {
     public init?(hexEncoded: String) {
         self.init(Array<UInt8>(hex: hexEncoded))
@@ -78,38 +59,9 @@ extension Data {
         return map { String(format: "%02hhx", $0) }.joined()
     }
     
-    @available(iOS 13.0, *)
-    func aesEncrypt(keyString: String) throws -> Data {
-        let key = try? SymmetricKey(passwordString: keyString)
-        
-        let nonce = Data(hexEncoded: "131348c0987c7eece60fc0bc") // = initialization vector
-        let tag = Data(hexEncoded: "5baa85ff3e7eda3204744ec74b71d523")
-        
-        print("debug tag \(tag?.hexDescription) nonce \(nonce?.hexDescription)")
-        let sealedData = try! AES.GCM.seal(self, using: key!, nonce: AES.GCM.Nonce(data: nonce!), authenticating: tag!)
-            
-        print("debug encrypted \(sealedData)")
-        guard let encryptedContent = sealedData.combined else {
-            throw CryptoKitError.underlyingCoreCryptoError(error: 2)
-        }
-        print("debug encrypted \(encryptedContent)")
-        print("debug encrypted \(encryptedContent.hexDescription)")
-        print("debug tag \(sealedData.tag.hexDescription)")
-        return encryptedContent
-    }
-    
-    @available(iOS 13.0, *)
-    func aesDecrypt(keyString: String) throws -> Data {
-        let key = try! SymmetricKey(passwordString: keyString)
-        let tag = Data(hexEncoded: "5baa85ff3e7eda3204744ec74b71d523")
-        
-        guard let sealedBox = try? AES.GCM.SealedBox(combined: self) else {
-            throw CryptoKitError.authenticationFailure
-        }
-        guard let decryptedData = try? AES.GCM.open(sealedBox, using: key, authenticating: tag!) else {
-            throw CryptoKitError.authenticationFailure
-        }
-        return decryptedData
+    var MD5: String {
+        let computed = Insecure.MD5.hash(data: self)
+        return computed.map { String(format: "%02hhx", $0) }.joined()
     }
 }
 
@@ -119,19 +71,50 @@ extension String {
         return computed.map { String(format: "%02hhx", $0) }.joined()
     }
     
-    func encodeAsFname() -> String {
-        var allowed = NSMutableCharacterSet.urlPathAllowed
-        allowed.remove(charactersIn: "&$@=;:+ ,?%#")
-        return self.addingPercentEncoding(withAllowedCharacters: allowed) ?? self
+    static func random(length: Int) -> String {
+        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+        return String((0..<length).map{ _ in letters.randomElement()! })
     }
     
-    func decodeFromFname() -> String {
-        return self.removingPercentEncoding ?? self
+    func fnameEncrypt(rawKey: Data) -> String? {
+        guard !self.isEmpty else {
+            return nil
+        }
+        guard let raw = self.data(using: .utf8) else {
+            return nil
+        }
+        
+        let key = SymmetricKey(data: rawKey)
+        let nonce = try! ChaChaPoly.Nonce(data: Data(repeating: 0, count: 12))
+        guard let sealed = try? ChaChaPoly.seal(raw, using: key, nonce: nonce) else { return nil }
+        
+        // strip nonce here, since it's all zero
+        return "e." + (sealed.ciphertext + sealed.tag).hexDescription
+
     }
     
-    static func random(length: Int) -> String {
-        let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
-        return String((0..<length).map{ _ in letters.randomElement()! })
+    func fnameDecrypt(rawKey: Data) -> String? {
+        // well-formated, non-empty encrypted string
+        guard self.hasPrefix("e.") && self.count > 36 else {
+            return nil
+        }
+        
+        let encryptedHex = self.suffix(from: self.index(self.startIndex, offsetBy: 2))
+        guard let encryptedRaw = Data(hexEncoded: String(encryptedHex)) else {
+            // invalid hex
+            return nil
+        }
+        
+        let key = SymmetricKey(data: rawKey)
+        let nonce = Data(repeating: 0, count: 12)
+
+        guard let sealed = try? ChaChaPoly.SealedBox(combined: nonce + encryptedRaw) else {
+            return nil
+        }
+        guard let outputRaw = try? ChaChaPoly.open(sealed, using: key) else {
+            return nil
+        }
+        return String(data: outputRaw, encoding: .utf8)
     }
 }
 
@@ -159,8 +142,8 @@ extension URL {
         return relComponents.joined(separator: "/")
     }
     
+    // Download a remote URL to a file
     func download(toFile file: URL, completion: @escaping (Error?) -> Void) {
-        // Download the remote URL to a file
         let task = URLSession.shared.downloadTask(with: self) {
             (tempURL, response, error) in
             // Early exit on error
@@ -188,14 +171,16 @@ extension URL {
                 // Remove any existing document at file
                 if FileManager.default.fileExists(atPath: file.path) {
                     try FileManager.default.removeItem(at: file)
+                } else {
+                    let baseURL = file.deletingLastPathComponent()
+                    try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
+                }
+                let rawData = try Data(contentsOf: tempURL)
+                guard let decryptedRawData = maybeDecrypt(rawData) else {
+                    throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "can not decrypt remote file"])
                 }
-                
-                // Copy the tempURL to file
-                try FileManager.default.copyItem(
-                    at: tempURL,
-                    to: file
-                )
-                
+                try decryptedRawData.write(to: file, options: .atomic)
+
                 completion(nil)
             }
             
@@ -209,3 +194,18 @@ extension URL {
         task.resume()
     }
 }
+
+// MARK: Crypto helper
+
+extension SymmetricKey {
+    public init(passwordString keyString: String) throws {
+        guard let keyData = keyString.data(using: .utf8) else {
+            print("ERROR: Could not create raw Data from String")
+            throw CryptoKitError.incorrectParameterSize
+        }
+        // SymmetricKeySize.bits256
+        let keyDigest = SHA256.hash(data: keyData)
+        
+        self.init(data: keyDigest)
+    }
+}

+ 4 - 0
ios/App/App/FileSync/FileSync.m

@@ -9,6 +9,7 @@
 
 CAP_PLUGIN(FileSync, "FileSync",
            CAP_PLUGIN_METHOD(setEnv, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(keygen, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(getLocalFilesMeta, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(getLocalAllFilesMeta, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(renameLocalFile, CAPPluginReturnPromise);
@@ -16,4 +17,7 @@ CAP_PLUGIN(FileSync, "FileSync",
            CAP_PLUGIN_METHOD(updateLocalFiles, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(deleteRemoteFiles, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(updateRemoteFiles, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(encryptFnames, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(decryptFnames, CAPPluginReturnPromise);
+           CAP_PLUGIN_METHOD(updateLocalVersionFiles, CAPPluginReturnPromise);
 )

+ 275 - 56
ios/App/App/FileSync/FileSync.swift

@@ -10,28 +10,80 @@ import Foundation
 import AWSMobileClient
 import CryptoKit
 
-// MARK: Global variables
+
+// MARK: Global variable
 
 // Defualts to dev
-var URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
+var URL_BASE = URL(string: "https://api-dev.logseq.com/file-sync/")!
 var BUCKET: String = "logseq-file-sync-bucket"
 var REGION: String = "us-east-2"
 
+var ENCRYPTION_SECRET_KEY: String? = nil
+var ENCRYPTION_PUBLIC_KEY: String? = nil
+var FNAME_ENCRYPTION_KEY: Data? = nil
+
+
+// MARK: Helpers
+
+
+@inline(__always) func fnameEncryptionEnabled() -> Bool {
+    guard let _ = FNAME_ENCRYPTION_KEY else {
+        return false
+    }
+    return true
+}
+
+// MARK: encryption helper
+
+func maybeEncrypt(_ plaindata: Data) -> Data! {
+    // avoid encryption twice
+    if plaindata.starts(with: "-----BEGIN AGE ENCRYPTED FILE-----".data(using: .utf8)!) ||
+        plaindata.starts(with: "age-encryption.org/v1\n".data(using: .utf8)!) {
+        return plaindata
+    }
+    if let publicKey = ENCRYPTION_PUBLIC_KEY {
+        // use armor = false, for smaller size
+        if let cipherdata = AgeEncryption.encryptWithX25519(plaindata, publicKey, armor: true) {
+            return cipherdata
+        }
+        return nil // encryption fail
+    }
+    return plaindata
+}
+
+func maybeDecrypt(_ cipherdata: Data) -> Data! {
+    if let secretKey = ENCRYPTION_SECRET_KEY {
+        if cipherdata.starts(with: "-----BEGIN AGE ENCRYPTED FILE-----".data(using: .utf8)!) ||
+            cipherdata.starts(with: "age-encryption.org/v1\n".data(using: .utf8)!) {
+            if let plaindata = AgeEncryption.decryptWithX25519(cipherdata, secretKey) {
+                return plaindata
+            }
+            return nil
+        }
+        // not an encrypted file
+        return cipherdata
+    }
+    return cipherdata
+}
+
+// MARK: Metadata type
 
 public struct SyncMetadata: CustomStringConvertible, Equatable {
     var md5: String
     var size: Int
+    var ctime: Int64
 
     public init?(of fileURL: URL) {
         do {
-            let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey])
+            let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey, .contentModificationDateKey])
             guard fileAttributes.isRegularFile! else {
                 return nil
             }
             size = fileAttributes.fileSize ?? 0
-            
-            // incremental MD5sum
-            let bufferSize = 1024 * 1024
+            ctime = Int64((fileAttributes.contentModificationDate?.timeIntervalSince1970 ?? 0.0) * 1000)
+
+            // incremental MD5 checksum
+            let bufferSize = 512 * 1024
             let file = try FileHandle(forReadingFrom: fileURL)
             defer {
                 file.closeFile()
@@ -46,7 +98,7 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
                     return false // eof
                 }
             }) {}
-            
+
             let computed = ctx.finalize()
             md5 = computed.map { String(format: "%02hhx", $0) }.joined()
         } catch {
@@ -59,13 +111,13 @@ public struct SyncMetadata: CustomStringConvertible, Equatable {
     }
 }
 
-
 // MARK: FileSync Plugin
 
 @objc(FileSync)
 public class FileSync: CAPPlugin, SyncDebugDelegate {
     override public func load() {
         print("debug File sync iOS plugin loaded!")
+
         AWSMobileClient.default().initialize { (userState, error) in
             guard error == nil else {
                 print("error initializing AWSMobileClient. Error: \(error!.localizedDescription)")
@@ -73,35 +125,99 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             }
         }
     }
-    
+
     // NOTE: for debug, or an activity indicator
     public func debugNotification(_ message: [String: Any]) {
         self.notifyListeners("debug", data: message)
     }
-    
+
+    @objc func keygen(_ call: CAPPluginCall) {
+        let (secretKey, publicKey) = AgeEncryption.keygen()
+        call.resolve(["secretKey": secretKey,
+                      "publicKey": publicKey])
+    }
+
+    @objc func setKey(_ call: CAPPluginCall) {
+        let secretKey = call.getString("secretKey")
+        let publicKey = call.getString("publicKey")
+        if secretKey == nil && publicKey == nil {
+            ENCRYPTION_SECRET_KEY = nil
+            ENCRYPTION_PUBLIC_KEY = nil
+            FNAME_ENCRYPTION_KEY = nil
+            return
+        }
+        guard let secretKey = secretKey, let publicKey = publicKey else {
+            call.reject("both secretKey and publicKey should be provided")
+            return
+        }
+        ENCRYPTION_SECRET_KEY = secretKey
+        ENCRYPTION_PUBLIC_KEY = publicKey
+        FNAME_ENCRYPTION_KEY = AgeEncryption.toRawX25519Key(secretKey)
+
+    }
+
     @objc func setEnv(_ call: CAPPluginCall) {
         guard let env = call.getString("env") else {
             call.reject("required parameter: env")
             return
         }
+        self.setKey(call)
+
         switch env {
         case "production", "product", "prod":
-            URL_BASE = URL(string: "https://api-prod.logseq.com/file-sync/")!
+            URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
             BUCKET = "logseq-file-sync-bucket-prod"
             REGION = "us-east-1"
         case "development", "develop", "dev":
-            URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
+            URL_BASE = URL(string: "https://api-dev.logseq.com/file-sync/")!
             BUCKET = "logseq-file-sync-bucket"
             REGION = "us-east-2"
         default:
             call.reject("invalid env: \(env)")
             return
         }
+
         self.debugNotification(["event": "setenv:\(env)"])
         call.resolve(["ok": true])
     }
-    
+
+    @objc func encryptFnames(_ call: CAPPluginCall) {
+        guard fnameEncryptionEnabled() else {
+            call.reject("fname encryption key not set")
+            return
+        }
+        guard var fnames = call.getArray("filePaths") as? [String] else {
+            call.reject("required parameters: filePaths")
+            return
+        }
+
+        let nFiles = fnames.count
+        fnames = fnames.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) }
+        if fnames.count != nFiles {
+            call.reject("cannot encrypt \(nFiles - fnames.count) file names")
+        }
+        call.resolve(["value": fnames])
+    }
+
+    @objc func decryptFnames(_ call: CAPPluginCall) {
+        guard fnameEncryptionEnabled() else {
+            call.reject("fname encryption key not set")
+            return
+        }
+        guard var fnames = call.getArray("filePaths") as? [String] else {
+            call.reject("required parameters: filePaths")
+            return
+        }
+        let nFiles = fnames.count
+        fnames = fnames.compactMap { $0.fnameDecrypt(rawKey: FNAME_ENCRYPTION_KEY!)?.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) }
+        if fnames.count != nFiles {
+            call.reject("cannot decrypt \(nFiles - fnames.count) file names")
+        }
+        call.resolve(["value": fnames])
+    }
+
     @objc func getLocalFilesMeta(_ call: CAPPluginCall) {
+        // filePaths are url encoded
         guard let basePath = call.getString("basePath"),
               let filePaths = call.getArray("filePaths") as? [String] else {
                   call.reject("required paremeters: basePath, filePaths")
@@ -111,34 +227,47 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             call.reject("invalid basePath")
             return
         }
-        
+
         var fileMetadataDict: [String: [String: Any]] = [:]
-        for filePath in filePaths {
+        for percentFilePath in filePaths {
+            let filePath = percentFilePath.removingPercentEncoding!
             let url = baseURL.appendingPathComponent(filePath)
             if let meta = SyncMetadata(of: url) {
-                fileMetadataDict[filePath] = ["md5": meta.md5,
-                                              "size": meta.size]
+                var metaObj: [String: Any] = ["md5": meta.md5,
+                                              "size": meta.size,
+                                              "ctime": meta.ctime]
+                if fnameEncryptionEnabled() {
+                    metaObj["encryptedFname"] = filePath.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!)
+                }
+
+                fileMetadataDict[percentFilePath] = metaObj
             }
         }
-        
+
         call.resolve(["result": fileMetadataDict])
     }
-    
+
     @objc func getLocalAllFilesMeta(_ call: CAPPluginCall) {
         guard let basePath = call.getString("basePath"),
               let baseURL = URL(string: basePath) else {
                   call.reject("invalid basePath")
                   return
               }
-        
+
         var fileMetadataDict: [String: [String: Any]] = [:]
         if let enumerator = FileManager.default.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsPackageDescendants, .skipsHiddenFiles]) {
-            
+
             for case let fileURL as URL in enumerator {
                 if !fileURL.isSkipped() {
                     if let meta = SyncMetadata(of: fileURL) {
-                        fileMetadataDict[fileURL.relativePath(from: baseURL)!] = ["md5": meta.md5,
-                                                                                  "size": meta.size]
+                        let filePath = fileURL.relativePath(from: baseURL)!
+                        var metaObj: [String: Any] = ["md5": meta.md5,
+                                                      "size": meta.size,
+                                                      "ctime": meta.ctime]
+                        if fnameEncryptionEnabled() {
+                            metaObj["encryptedFname"] = filePath.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!)
+                        }
+                        fileMetadataDict[filePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!] = metaObj
                     }
                 } else if fileURL.isICloudPlaceholder() {
                     try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
@@ -147,8 +276,8 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
         }
         call.resolve(["result": fileMetadataDict])
     }
-    
-    
+
+
     @objc func renameLocalFile(_ call: CAPPluginCall) {
         guard let basePath = call.getString("basePath"),
               let baseURL = URL(string: basePath) else {
@@ -163,10 +292,10 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             call.reject("invalid to file")
             return
         }
-        
-        let fromUrl = baseURL.appendingPathComponent(from)
-        let toUrl = baseURL.appendingPathComponent(to)
-        
+
+        let fromUrl = baseURL.appendingPathComponent(from.removingPercentEncoding!)
+        let toUrl = baseURL.appendingPathComponent(to.removingPercentEncoding!)
+
         do {
             try FileManager.default.moveItem(at: fromUrl, to: toUrl)
         } catch {
@@ -174,23 +303,23 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             return
         }
         call.resolve(["ok": true])
-        
+
     }
-    
+
     @objc func deleteLocalFiles(_ call: CAPPluginCall) {
         guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
               let filePaths = call.getArray("filePaths") as? [String] else {
                   call.reject("required paremeters: basePath, filePaths")
                   return
               }
-        
+
         for filePath in filePaths {
-            let fileUrl = baseURL.appendingPathComponent(filePath)
+            let fileUrl = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
             try? FileManager.default.removeItem(at: fileUrl) // ignore any delete errors
         }
         call.resolve(["ok": true])
     }
-    
+
     /// remote -> local
     @objc func updateLocalFiles(_ call: CAPPluginCall) {
         guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
@@ -200,11 +329,27 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
                   call.reject("required paremeters: basePath, filePaths, graphUUID, token")
                   return
               }
-        
+
+        // [encrypted-fname: original-fname]
+        var encryptedFilePathDict: [String: String] = [:]
+        if fnameEncryptionEnabled() {
+            for filePath in filePaths {
+                if let encryptedPath = filePath.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) {
+                    encryptedFilePathDict[encryptedPath] = filePath
+                } else {
+                    call.reject("cannot decrypt all file names")
+                }
+            }
+        } else {
+            encryptedFilePathDict = Dictionary(uniqueKeysWithValues: filePaths.map { ($0, $0) })
+        }
+
+        let encryptedFilePaths = Array(encryptedFilePathDict.keys)
+
         let client = SyncClient(token: token, graphUUID: graphUUID)
         client.delegate = self // receives notification
-        
-        client.getFiles(at: filePaths) {  (fileURLs, error) in
+
+        client.getFiles(at: encryptedFilePaths) {  (fileURLs, error) in
             if let error = error {
                 print("debug getFiles error \(error)")
                 self.debugNotification(["event": "download:error", "data": ["message": "error while getting files \(filePaths)"]])
@@ -212,14 +357,15 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             } else {
                 // handle multiple completionHandlers
                 let group = DispatchGroup()
-                
+
                 var downloaded: [String] = []
-                
-                for (filePath, remoteFileURL) in fileURLs {
+
+                for (encryptedFilePath, remoteFileURL) in fileURLs {
                     group.enter()
 
+                    let filePath = encryptedFilePathDict[encryptedFilePath]!
                     // NOTE: fileURLs from getFiles API is percent-encoded
-                    let localFileURL = baseURL.appendingPathComponent(filePath.decodeFromFname())
+                    let localFileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
                     remoteFileURL.download(toFile: localFileURL) {error in
                         if let error = error {
                             self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
@@ -235,13 +381,61 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
                     self.debugNotification(["event": "download:done"])
                     call.resolve(["ok": true, "data": downloaded])
                 }
-                
+
+            }
+        }
+    }
+
+    @objc func updateLocalVersionFiles(_ call: CAPPluginCall) {
+        guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
+              let filePaths = call.getArray("filePaths") as? [String],
+              let graphUUID = call.getString("graphUUID") ,
+              let token = call.getString("token") else {
+                  call.reject("required paremeters: basePath, filePaths, graphUUID, token")
+                  return
+              }
+        let client = SyncClient(token: token, graphUUID: graphUUID)
+        client.delegate = self // receives notification
+
+        client.getVersionFiles(at: filePaths) {  (fileURLDict, error) in
+            if let error = error {
+                print("debug getVersionFiles error \(error)")
+                self.debugNotification(["event": "version-download:error", "data": ["message": "error while getting version files \(filePaths)"]])
+                call.reject(error.localizedDescription)
+            } else {
+                // handle multiple completionHandlers
+                let group = DispatchGroup()
+
+                var downloaded: [String] = []
+
+                for (filePath, remoteFileURL) in fileURLDict {
+                    group.enter()
+
+                    // NOTE: fileURLs from getFiles API is percent-encoded
+                    let localFileURL = baseURL.appendingPathComponent("logseq/version-files/").appendingPathComponent(filePath.removingPercentEncoding!)
+                    remoteFileURL.download(toFile: localFileURL) {error in
+                        if let error = error {
+                            self.debugNotification(["event": "version-download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
+                            print("debug download \(error) in \(filePath)")
+                        } else {
+                            self.debugNotification(["event": "version-download:file", "data": ["file": filePath]])
+                            downloaded.append(filePath)
+                        }
+                        group.leave()
+                    }
+                }
+                group.notify(queue: .main) {
+                    self.debugNotification(["event": "version-download:done"])
+                    call.resolve(["ok": true, "data": downloaded])
+                }
+
             }
         }
     }
-    
+
+    // filePaths: Encrypted file paths
     @objc func deleteRemoteFiles(_ call: CAPPluginCall) {
-        guard let filePaths = call.getArray("filePaths") as? [String],
+        guard var filePaths = call.getArray("filePaths") as? [String],
               let graphUUID = call.getString("graphUUID"),
               let token = call.getString("token"),
               let txid = call.getInt("txid") else {
@@ -252,7 +446,15 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             call.reject("empty filePaths")
             return
         }
-        
+
+        let nFiles = filePaths.count
+        if fnameEncryptionEnabled() {
+            filePaths = filePaths.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) }
+        }
+        if filePaths.count != nFiles {
+            call.reject("cannot encrypt all file names")
+        }
+
         let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
         client.deleteFiles(filePaths) { txid, error in
             guard error == nil else {
@@ -266,7 +468,7 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
             call.resolve(["ok": true, "txid": txid])
         }
     }
-    
+
     /// local -> remote
     @objc func updateRemoteFiles(_ call: CAPPluginCall) {
         guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
@@ -277,15 +479,15 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
                   call.reject("required paremeters: basePath, filePaths, graphUUID, token, txid")
                   return
               }
+        let fnameEncryption = call.getBool("fnameEncryption") ?? false // default to false
+
         guard !filePaths.isEmpty else {
             return call.reject("empty filePaths")
         }
-        
-        print("debug begin updateRemoteFiles \(filePaths)")
-        
+
         let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
         client.delegate = self
-        
+
         // 1. refresh_temp_credential
         client.getTempCredential() { (credentials, error) in
             guard error == nil else {
@@ -293,16 +495,16 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
                 call.reject("error(getTempCredential): \(error!)")
                 return
             }
-            
+
             var files: [String: URL] = [:]
             for filePath in filePaths {
                 // NOTE: filePath from js may contain spaces
-                let fileURL = baseURL.appendingPathComponent(filePath)
-                files[filePath.encodeAsFname()] = fileURL
+                let fileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!)
+                files[filePath] = fileURL
             }
-            
+
             // 2. upload_temp_file
-            client.uploadTempFiles(files, credentials: credentials!) { (uploadedFileKeyDict, error) in
+            client.uploadTempFiles(files, credentials: credentials!) { (uploadedFileKeyDict, fileMd5Dict, error) in
                 guard error == nil else {
                     self.debugNotification(["event": "upload:error", "data": ["message": "error while uploading temp files: \(error!)"]])
                     call.reject("error(uploadTempFiles): \(error!)")
@@ -314,7 +516,24 @@ public class FileSync: CAPPlugin, SyncDebugDelegate {
                     call.reject("no file to update")
                     return
                 }
-                client.updateFiles(uploadedFileKeyDict) { (txid, error) in
+
+                // encrypted-file-name: (file-key, md5)
+                var uploadedFileKeyMd5Dict: [String: [String]] = [:]
+
+                if fnameEncryptionEnabled() && fnameEncryption {
+                    for (filePath, fileKey) in uploadedFileKeyDict {
+                        guard let encryptedFilePath = filePath.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) else {
+                            call.reject("cannot encrypt file name")
+                            return
+                        }
+                        uploadedFileKeyMd5Dict[encryptedFilePath] = [fileKey, fileMd5Dict[filePath]!]
+                    }
+                } else {
+                    for (filePath, fileKey) in uploadedFileKeyDict {
+                        uploadedFileKeyMd5Dict[filePath] = [fileKey, fileMd5Dict[filePath]!]
+                    }
+                }
+                client.updateFiles(uploadedFileKeyMd5Dict) { (txid, error) in
                     guard error == nil else {
                         self.debugNotification(["event": "upload:error", "data": ["message": "error while updating files: \(error!)"]])
                         call.reject("error updateFiles: \(error!)")

+ 64 - 9
ios/App/App/FileSync/SyncClient.swift

@@ -49,7 +49,7 @@ public class SyncClient {
         
         let payload = [
             "GraphUUID": self.graphUUID ?? "",
-            "Files": filePaths.map { filePath in filePath.encodeAsFname()}
+            "Files": filePaths
         ] as [String : Any]
         let bodyData = try? JSONSerialization.data(
             withJSONObject: payload,
@@ -83,6 +83,50 @@ public class SyncClient {
         task.resume()
     }
     
+    public func getVersionFiles(at filePaths: [String], completionHandler: @escaping ([String: URL], Error?) -> Void) {
+        let url = URL_BASE.appendingPathComponent("get_version_files")
+        
+        var request = URLRequest(url: url)
+        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
+        request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent")
+        request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization")
+        
+        let payload = [
+            "GraphUUID": self.graphUUID ?? "",
+            "Files": filePaths
+        ] as [String : Any]
+        let bodyData = try? JSONSerialization.data(
+            withJSONObject: payload,
+            options: []
+        )
+        request.httpMethod = "POST"
+        request.httpBody = bodyData
+        
+        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
+            guard error == nil else {
+                completionHandler([:], error)
+                return
+            }
+            
+            if (response as? HTTPURLResponse)?.statusCode != 200 {
+                let body = String(data: data!, encoding: .utf8) ?? "";
+                completionHandler([:], NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "http error \(body)"]))
+                return
+            }
+            
+            if let data = data {
+                let resp = try? JSONDecoder().decode([String:[String:String]].self, from: data)
+                let files = resp?["PresignedFileUrls"] ?? [:]
+                self.delegate?.debugNotification(["event": "version-download:prepare"])
+                completionHandler(files.mapValues({ url in URL(string: url)!}), nil)
+            } else {
+                // Handle unexpected error
+                completionHandler([:], NSError(domain: "", code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"]))
+            }
+        }
+        task.resume()
+    }
+    
     
     public func deleteFiles(_ filePaths: [String], completionHandler: @escaping  (Int?, Error?) -> Void) {
         let url = URL_BASE.appendingPathComponent("delete_files")
@@ -94,7 +138,7 @@ public class SyncClient {
         
         let payload = [
             "GraphUUID": self.graphUUID ?? "",
-            "Files": filePaths.map { filePath in filePath.encodeAsFname()},
+            "Files": filePaths,
             "TXId": self.txid,
         ] as [String : Any]
         let bodyData = try? JSONSerialization.data(
@@ -148,7 +192,8 @@ public class SyncClient {
     }
     
     // (txid, error)
-    public func updateFiles(_ fileKeyDict: [String: String], completionHandler: @escaping  (Int?, Error?) -> Void) {
+    // filePath => [S3Key, md5]
+    public func updateFiles(_ fileKeyDict: [String: [String]], completionHandler: @escaping  (Int?, Error?) -> Void) {
         let url = URL_BASE.appendingPathComponent("update_files")
         
         var request = URLRequest(url: url)
@@ -158,7 +203,7 @@ public class SyncClient {
         
         let payload = [
             "GraphUUID": self.graphUUID ?? "",
-            "Files": Dictionary(uniqueKeysWithValues: fileKeyDict.map { ($0, $1) }) as [String: String] as Any,
+            "Files": Dictionary(uniqueKeysWithValues: fileKeyDict.map { ($0, $1) }) as [String: [String]] as Any,
             "TXId": self.txid,
         ] as [String : Any]
         let bodyData = try? JSONSerialization.data(
@@ -252,10 +297,17 @@ public class SyncClient {
     }
     
     // [filePath, Key]
-    public func uploadTempFiles(_ files: [String: URL], credentials: S3Credential, completionHandler: @escaping ([String: String], Error?) -> Void) {
+    public func uploadTempFiles(_ files: [String: URL], credentials: S3Credential, completionHandler: @escaping ([String: String], [String: String], Error?) -> Void) {
         let credentialsProvider = AWSBasicSessionCredentialsProvider(
             accessKey: credentials.AccessKeyId, secretKey: credentials.SecretKey, sessionToken: credentials.SessionToken)
-        let configuration = AWSServiceConfiguration(region: .USEast2, credentialsProvider: credentialsProvider)
+        var region = AWSRegionType.USEast2
+        if REGION == "us-east-2" {
+            region = .USEast2
+        } else if REGION == "us-east-1" {
+            region = .USEast1
+        } // TODO: string to REGION conversion
+        
+        let configuration = AWSServiceConfiguration(region: region, credentialsProvider: credentialsProvider)
         configuration?.timeoutIntervalForRequest = 5.0
         configuration?.timeoutIntervalForResource = 5.0
         
@@ -280,6 +332,7 @@ public class SyncClient {
         let group = DispatchGroup()
         var keyFileDict: [String: String] = [:]
         var fileKeyDict: [String: String] = [:]
+        var fileMd5Dict: [String: String] = [:]
         
         let uploadCompletionHandler = { (task: AWSS3TransferUtilityUploadTask, error: Error?) -> Void in
             // ignore any errors in first level of handler
@@ -303,16 +356,18 @@ public class SyncClient {
         for (filePath, fileLocalURL) in files {
             print("debug, upload temp \(fileLocalURL) \(filePath)")
             guard let rawData = try? Data(contentsOf: fileLocalURL) else { continue }
+            guard let encryptedRawDat = maybeEncrypt(rawData) else { continue }
             group.enter()
             
             let randFileName = String.random(length: 15).appending(".").appending(fileLocalURL.pathExtension)
             let key = "\(self.s3prefix!)/ios\(randFileName)"
 
             keyFileDict[key] = filePath
-            transferUtility?.uploadData(rawData, key: key, contentType: "application/octet-stream", expression: uploadExpression, completionHandler: uploadCompletionHandler)
+            fileMd5Dict[filePath] = rawData.MD5
+            transferUtility?.uploadData(encryptedRawDat, key: key, contentType: "application/octet-stream", expression: uploadExpression, completionHandler: uploadCompletionHandler)
                 .continueWith(block: { (task) in
                     if let error = task.error {
-                        completionHandler([:], error)
+                        completionHandler([:], [:], error)
                     }
                     return nil
                 })
@@ -320,7 +375,7 @@ public class SyncClient {
         
         group.notify(queue: .main) {
             AWSS3TransferUtility.remove(forKey: transferKey)
-            completionHandler(fileKeyDict, nil)
+            completionHandler(fileKeyDict, fileMd5Dict, nil)
         }
     }
 }

+ 31 - 28
ios/App/App/FsWatcher.swift

@@ -14,11 +14,11 @@ import Capacitor
 public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
     private var watcher: PollingWatcher? = nil
     private var baseUrl: URL? = nil
-    
+
     override public func load() {
         print("debug FsWatcher iOS plugin loaded!")
     }
-    
+
     @objc func watch(_ call: CAPPluginCall) {
         if let path = call.getString("path") {
             guard let url = URL(string: path) else {
@@ -28,22 +28,22 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
             self.baseUrl = url
             self.watcher = PollingWatcher(at: url)
             self.watcher?.delegate = self
-            
+
             call.resolve(["ok": true])
-            
+
         } else {
             call.reject("missing path string parameter")
         }
     }
-    
+
     @objc func unwatch(_ call: CAPPluginCall) {
         watcher?.stop()
         watcher = nil
         baseUrl = nil
-        
+
         call.resolve()
     }
-    
+
     public func recevedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
         // NOTE: Event in js {dir path content stat{mtime}}
         switch event {
@@ -67,7 +67,7 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
                                                             "ctime": metadata?.creationTimestamp ?? 0,
                                                             "size": metadata?.fileSize as Any]
                                                   ])
-            
+
         case .Error:
             // TODO: handle error?
             break
@@ -83,12 +83,15 @@ extension URL {
         if self.lastPathComponent.starts(with: ".") {
             return true
         }
+        if self.absoluteString.contains("/logseq/bak/") || self.absoluteString.contains("/logseq/version-files/"){
+            return true
+        }
         if self.lastPathComponent == "graphs-txid.edn" || self.lastPathComponent == "broken-config.edn" {
             return true
         }
         return false
     }
-    
+
     func shouldNotifyWithContent() -> Bool {
         let allowedPathExtensions: Set = ["md", "markdown", "org", "js", "edn", "css", "excalidraw"]
         if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
@@ -96,7 +99,7 @@ extension URL {
         }
         return false
     }
-    
+
     func isICloudPlaceholder() -> Bool {
         if self.lastPathComponent.starts(with: ".") && self.pathExtension.lowercased() == "icloud" {
             return true
@@ -116,7 +119,7 @@ public enum PollingWatcherEvent {
     case Change
     case Unlink
     case Error
-    
+
     var description: String {
         switch self {
         case .Add:
@@ -136,7 +139,7 @@ public struct SimpleFileMetadata: CustomStringConvertible, Equatable {
     var contentModificationTimestamp: Double
     var creationTimestamp: Double
     var fileSize: Int
-    
+
     public init?(of fileURL: URL) {
         do {
             let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey, .contentModificationDateKey, .creationDateKey])
@@ -151,7 +154,7 @@ public struct SimpleFileMetadata: CustomStringConvertible, Equatable {
             return nil
         }
     }
-    
+
     public var description: String {
         return "Meta(size=\(self.fileSize), mtime=\(self.contentModificationTimestamp), ctime=\(self.creationTimestamp)"
     }
@@ -162,10 +165,10 @@ public class PollingWatcher {
     private var timer: DispatchSourceTimer?
     public var delegate: PollingWatcherDelegate? = nil
     private var metaDb: [URL: SimpleFileMetadata] = [:]
-    
+
     public init?(at: URL) {
         url = at
-        
+
         let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
         timer = DispatchSource.makeTimerSource(queue: queue)
         timer!.setEventHandler(qos: .background, flags: []) { [weak self] in
@@ -173,29 +176,29 @@ public class PollingWatcher {
         }
         timer!.schedule(deadline: .now())
         timer!.resume()
-        
+
     }
-    
+
     deinit {
         self.stop()
     }
-    
+
     public func stop() {
         timer?.cancel()
         timer = nil
     }
-    
+
     private func tick() {
         // let startTime = DispatchTime.now()
-        
+
         if let enumerator = FileManager.default.enumerator(
             at: url,
             includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey],
             // NOTE: icloud downloading requires non-skipsHiddenFiles
             options: [.skipsPackageDescendants]) {
-            
+
             var newMetaDb: [URL: SimpleFileMetadata] = [:]
-            
+
             for case let fileURL as URL in enumerator {
                 guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]),
                       let isDirectory = resourceValues.isDirectory,
@@ -204,14 +207,14 @@ public class PollingWatcher {
                 else {
                     continue
                 }
-                
+
                 if isDirectory {
                     // NOTE: URL.path won't end with a `/`
                     if fileURL.path.hasSuffix("/logseq/bak") || fileURL.path.hasSuffix("/logseq/version-files") || name == ".recycle" || name.hasPrefix(".") || name == "node_modules" {
                         enumerator.skipDescendants()
                     }
                 }
-                
+
                 if isRegularFile && !fileURL.isSkipped() {
                     if let meta = SimpleFileMetadata(of: fileURL) {
                         newMetaDb[fileURL] = meta
@@ -220,14 +223,14 @@ public class PollingWatcher {
                     try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
                 }
             }
-            
+
             self.updateMetaDb(with: newMetaDb)
         }
-        
+
         // let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
         // let elapsedInMs = Double(elapsedNanoseconds) / 1_000_000
         // print("debug ticker elapsed=\(elapsedInMs)ms")
-        
+
         if #available(iOS 13.0, *) {
             timer?.schedule(deadline: .now().advanced(by: .seconds(2)), leeway: .milliseconds(100))
         } else {
@@ -235,7 +238,7 @@ public class PollingWatcher {
             timer?.schedule(deadline: .now() + 2.0, leeway: .milliseconds(100))
         }
     }
-    
+
     // TODO: batch?
     private func updateMetaDb(with newMetaDb: [URL: SimpleFileMetadata]) {
         for (url, meta) in newMetaDb {

+ 22 - 0
ios/App/LogseqSpecs/AgeEncryption.podspec

@@ -0,0 +1,22 @@
+Pod::Spec.new do |s|
+    s.name             = "AgeEncryption"
+    s.version          = "1.0.6"
+    s.summary          = "AgeEncryption for Logseq"
+    s.description      = <<-DESC
+                         TODO: Add description
+                         DESC
+    s.homepage         = "https://github.com/andelf/AgeEncryption"
+    s.license          = 'MIT'
+    s.author           = { "Andelf" => "[email protected]" }
+    s.source           = { :http => "https://github.com/andelf/AgeEncryption/releases/download/#{s.version}/AgeEncryption.xcframework.zip" }
+
+    s.requires_arc          = true
+
+    s.platform = :ios
+    s.ios.deployment_target = '12.0'
+
+    s.vendored_frameworks = "AgeEncryption.xcframework"
+    s.static_framework = true
+
+    s.swift_version = '5.1'
+  end

+ 1 - 0
ios/App/Podfile

@@ -27,4 +27,5 @@ target 'Logseq' do
   # Add your Pods here
   pod 'AWSMobileClient'
   pod 'AWSS3'
+  pod 'AgeEncryption', :podspec => './LogseqSpecs/AgeEncryption.podspec'
 end

+ 1 - 0
libs/.gitignore

@@ -0,0 +1 @@
+docs/

+ 4 - 1
libs/package.json

@@ -12,7 +12,8 @@
     "dev:core": "npm run build:core -- --mode development --watch",
     "build": "tsc && rm dist/*.js && npm run build:user",
     "lint": "prettier --check \"src/**/*.{ts, js}\"",
-    "fix": "prettier --write \"src/**/*.{ts, js}\""
+    "fix": "prettier --write \"src/**/*.{ts, js}\"",
+    "build:docs": "typedoc --plugin typedoc-plugin-lsp-docs src/LSPlugin.user.ts"
   },
   "dependencies": {
     "csstype": "3.1.0",
@@ -32,6 +33,8 @@
     "prettier-config-standard": "^5.0.0",
     "ts-loader": "9.3.0",
     "typescript": "4.7.3",
+    "typedoc": "^0.22.15",
+    "typedoc-plugin-lsp-docs": "*",
     "webpack": "5.73.0",
     "webpack-bundle-analyzer": "4.5.0",
     "webpack-cli": "4.9.2"

+ 2 - 0
package.json

@@ -85,8 +85,10 @@
         "@sentry/tracing": "^6.18.2",
         "@tabler/icons": "1.54.0",
         "@tippyjs/react": "4.2.5",
+        "aes-js": "3.1.2",
         "bignumber.js": "^9.0.2",
         "capacitor-voice-recorder": "2.1.0",
+        "check-password-strength": "2.0.7",
         "chokidar": "3.5.1",
         "chrono-node": "2.2.4",
         "codemirror": "5.58.1",

+ 59 - 0
resources/css/tabler-extension.css

@@ -0,0 +1,59 @@
+@font-face {
+  font-family: "tabler-icons-extension";
+  src: url("../fonts/tabler-icons-extension.woff2?6z5ubs") format("woff2");
+  font-style: normal;
+  font-weight: 400;
+}
+
+.tie {
+  display: inline-block;
+  font-family: "tabler-icons-extension" !important;
+  font-style: normal !important;
+  font-weight: normal !important;
+  font-variant: normal !important;
+  text-transform: none !important;
+  speak: none;
+  line-height: 1;
+  vertical-align: -.125em;
+  text-rendering: auto;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+
+.tie-app-feature::before {
+  content: "\ea01";
+}
+.tie-block::before {
+  content: "\ea02";
+}
+.tie-block-search::before {
+  content: "\ea03";
+}
+.tie-connector::before {
+  content: "\ea04";
+}
+.tie-page::before {
+  content: "\ea05";
+}
+.tie-page-search::before {
+  content: "\ea06";
+}
+.tie-references-hide::before {
+  content: "\ea07";
+}
+.tie-references-show::before {
+  content: "\ea08";
+}
+.tie-select-cursor::before {
+  content: "\ea09";
+}
+.tie-text::before {
+  content: "\ea0a";
+}
+.tie-whiteboard::before {
+  content: "\ea0b";
+}
+.tie-whiteboard-element::before {
+  content: "\ea0c";
+}

BIN
resources/fonts/tabler-icons-extension.woff2


BIN
resources/img/file-sync-unavailale-nonbacker-dark.png


BIN
resources/img/file-sync-unavailale-nonbacker-light.png


BIN
resources/img/file-sync-welcome-backer-dark.png


BIN
resources/img/file-sync-welcome-backer-light.png


+ 1 - 1
resources/package.json

@@ -36,7 +36,7 @@
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
     "posthog-js": "1.10.2",
-    "@logseq/rsapi": "0.0.20",
+    "@logseq/rsapi": "0.0.33",
     "electron-deeplink": "1.0.10",
     "abort-controller": "3.0.0"
   },

+ 87 - 49
scripts/src/logseq/tasks/file_sync.clj

@@ -3,23 +3,24 @@
 
 * Login to electron app and toggle file-sync on
 * Set up file-sync-auth.json file per #'read-config
-* Run `bb file-sync:integration-tests GRAPH_DIRECTORY`
+* Run `bb file-sync:integration-tests`
 * Wait for test results. Each action takes 10-20s and prints results as it goes"
   (:require [clojure.string :as str]
             [cheshire.core :as json]
+            [clojure.pprint :as pp]
             [babashka.fs :as fs]
             [babashka.curl :as curl]
             [clojure.data :as data]
-            [clojure.test :as t :refer [deftest is]])
+            [clojure.test :as t :refer [deftest is]]
+            [clj-commons.digest :as digest]
+            [logseq.tasks.file-sync-actions :as file-sync-actions])
   (:import (java.net URLDecoder)))
 
-(def root-dir
-  "Root directory for graph that is being tested"
-  (atom nil))
+;; Root directory for graph that is being tested
+(defonce root-dir (atom nil))
 
-(def root-graph-id
-  "Graph id for given graph"
-  (atom nil))
+;; Graph id for given graph
+(defonce root-graph-id (atom nil))
 
 (defn- read-config*
   []
@@ -42,24 +43,31 @@
 
 (defn- build-headers
   []
-  (let [{:strs [access_token]} (read-config)]
-    {"authorization" (str "Bearer " access_token)}))
+  (let [{:strs [id_token]} (read-config)]
+    {"authorization" (str "Bearer " id_token)}))
 
 (defn- api-get-all-files
   [graph-id subdir]
   (let [body (json/generate-string {"GraphUUID" graph-id
                                     "Dir" subdir})
-        resp (post "https://api.logseq.com/file-sync/get_all_files"
+        resp (post "https://api-dev.logseq.com/file-sync/get_all_files"
                    {:headers (build-headers)
                     :body body})
         body (json/parse-string (:body resp) keyword)]
     (->> body
          :Objects
-         (map (comp #(URLDecoder/decode %) fs/file-name :Key)))))
+         (map (juxt (comp #(URLDecoder/decode %) fs/file-name :Key) :checksum)))))
+
+(defn- get-local-all-files
+  [dir subdir]
+  (let [files (map fs/file (fs/list-dir (fs/file dir subdir)))
+        f (juxt fs/file-name digest/md5)]
+    (map f files)))
+
 
 (defn- api-post-get-graphs
   []
-  (let [resp (post "https://api.logseq.com/file-sync/list_graphs"
+  (let [resp (post "https://api-dev.logseq.com/file-sync/list_graphs"
                    {:headers (build-headers)})
         body (json/parse-string (:body resp) keyword)]
     (->> body
@@ -85,13 +93,24 @@
   (fs/move (fs/file dir file)
            (fs/file dir new-file)))
 
+(defmethod run-action* :update-file
+  [{{:keys [file blocks dir]} :args}]
+  (let [origin-content (slurp (fs/file dir file))
+        new-content (str (str/trim-newline origin-content) "\n"
+                         (->> blocks
+                              (map #(str "- " %))
+                              (str/join "\n")))]
+    (spit (fs/file dir file) new-content)))
+
 (defn run-action [action-map]
-  (println "\n===\nRUN" (pr-str action-map) "\n===")
+  (println "===\nRUN")
+  (pp/pprint ((juxt :action #(get-in % [:args :file])) action-map))
+  (println "===")
   (run-action* action-map))
 
 (defn- ensure-dir-is-synced!
   [dir graph-id subdir]
-  (let [actual (set (map fs/file-name (fs/list-dir (fs/file dir subdir))))
+  (let [actual (set (get-local-all-files dir subdir))
         expected (set (api-get-all-files graph-id subdir))]
     (assert (= actual expected)
             (let [[local-only remote-only _] (data/diff actual expected)]
@@ -119,45 +138,64 @@
 
 (defn- files-are-in-sync?
   [dir graph-id subdir]
+  (try (ensure-dir-is-synced! dir graph-id subdir)
+       true
+       (catch Throwable e
+         (println (.getMessage e))
+         false)))
+
+(defn- wait&files-are-in-sync?
+  [dir graph-id subdir & [msg]]
   ;; Approximate polling time before file changes are picked up by client
-  (println "Wait 10s for logseq to pick up changes...")
+  (if msg
+    (println msg)
+    (println "Wait 10s for logseq to pick up changes..."))
   (Thread/sleep 10000)
-  (try-fn-n-times (fn []
-                    (try (ensure-dir-is-synced! dir graph-id subdir)
-                      true
-                      (catch Throwable e
-                        (println (.getMessage e))
-                        false)))
-                  10))
-
-(deftest file-changes
+  (try-fn-n-times #(files-are-in-sync? dir graph-id subdir) 10))
+
+(defn- clear-dir-pages
+  [subdir]
+  (fs/delete-tree (str @root-dir "/" subdir))
+  (fs/create-dir (str @root-dir "/" subdir)))
+
+(deftest rand-file-changes
   (let [subdir "pages"
-        ;; Directory must be in sync in order for assertions to pass
-        _ (ensure-dir-is-synced! @root-dir @root-graph-id subdir)
-        ;; These actions are data driven which allows us to spec to generate them
-        ;; when the API is able to handle more randomness
+        actions (:generated-action (file-sync-actions/generate-rand-actions 30))
         actions (mapv
                  #(assoc-in % [:args :dir] @root-dir)
-                 [{:action :create-file
-                   :args {:file (str subdir "/test.create-page.md")
-                          :blocks ["hello world"]}}
-                  {:action :move-file
-                   :args {:file (str subdir "/test.create-page.md")
-                          :new-file (str subdir "/test.create-page-new.md")}}
-                  {:action :delete-file
-                   :args {:file (str subdir "/test.create-page-new.md")}}])]
-
-    (doseq [action-map actions]
-      (run-action action-map)
-      (is (files-are-in-sync? @root-dir @root-graph-id subdir)
-          (str "Test " (select-keys action-map [:action]))))))
-
-(defn integration-tests
-  "Run file-sync integration tests on graph directory"
-  [dir & _args]
-  (let [graph-names-to-ids (api-post-get-graphs)
+                 actions)
+        partitioned-actions (partition-all 3 actions)]
+    (clear-dir-pages subdir)
+    (wait&files-are-in-sync? @root-dir @root-graph-id subdir
+                             (format "clear dir [%s], and ensure it's in sync" subdir))
+    (doseq [actions partitioned-actions]
+      (doseq [action actions]
+        (run-action action)
+        (Thread/sleep 1000))
+      (is (wait&files-are-in-sync? @root-dir @root-graph-id subdir)
+          (str "Test " (mapv (juxt :action #(get-in % [:args :file])) actions))))))
+
+(defn setup-vars
+  []
+  (let [{:strs [dir]} (read-config)
+        graph-names-to-ids (api-post-get-graphs)
         graph-id (get graph-names-to-ids (fs/file-name dir))]
     (assert dir "No graph id for given dir")
     (reset! root-dir dir)
-    (reset! root-graph-id graph-id)
-    (t/run-tests 'logseq.tasks.file-sync)))
+    (reset! root-graph-id graph-id)))
+
+(defn integration-tests
+  "Run file-sync integration tests on graph directory
+  requirements:
+
+  * file-sync-auth.json, and it looks like:
+  ```
+  {\"id_token\":\"<id-token>\",
+    \"dir\": \"/Users/me/Documents/untitled folder 31\"}
+  ```
+
+  * you alse need to open logseq-app(or yarn electron-watch),
+    and open <dir> and start file-sync"
+  [& _args]
+  (setup-vars)
+  (t/run-tests 'logseq.tasks.file-sync))

+ 164 - 0
scripts/src/logseq/tasks/file_sync_actions.clj

@@ -0,0 +1,164 @@
+(ns logseq.tasks.file-sync-actions
+  (:require [clojure.test.check.generators :as gen]))
+
+
+(defmulti gen-action* (fn [& args] (first args)))
+
+(defmethod gen-action* :create-file
+  [_ page-index & _args]
+  (gen/let [blocks (gen/vector gen/string-alphanumeric 1 10)]
+    {:action :create-file
+     :args {:file (format "pages/test.page-%d.md" page-index)
+            :blocks blocks}}))
+
+(defmethod gen-action* :move-file
+  [_ origin-page-index & [moved?]]
+  (let [page-name (if moved?
+                      (format "pages/test.page-move-%d.md" origin-page-index)
+                      (format "pages/test.page-%d.md" origin-page-index))]
+    (gen/return
+     {:action :move-file
+      :args {:file page-name
+             :new-file (format "pages/test.page-move-%d.md" origin-page-index)}})))
+
+(defmethod gen-action* :update-file
+  [_ page-index & [moved?]]
+  (gen/let [append-blocks (gen/vector gen/string-alphanumeric 1 10)]
+    (let [page-name (if moved?
+                      (format "pages/test.page-move-%d.md" page-index)
+                      (format "pages/test.page-%d.md" page-index))]
+      {:action :update-file
+       :args {:file page-name
+              :blocks append-blocks}})))
+
+(defmethod gen-action* :delete-file
+  [_ page-index & [moved?]]
+  (let [page-name (if moved?
+                    (format "pages/test.page-move-%d.md" page-index)
+                    (format "pages/test.page-%d.md" page-index))]
+    (gen/return
+     {:action :delete-file
+      :args {:file page-name}})))
+
+
+(defmacro gen-actions-plan
+  "state monad
+  state: {:page-index [{:index 1 :moved? false}, ...]
+          :generated-action [...]}
+
+  (gen-actions-plan
+     [id+moved? get-rand-available-index-op
+      _ (when-op id+moved? (apply action-op action id+moved?))]
+     nil)"
+  [binds val-expr]
+  (let [binds (partition 2 binds)
+        psym (gensym "state_")
+        forms (reduce (fn [acc [id expr]]
+                        (concat acc `[[~id ~psym] (~expr ~psym)]))
+                      []
+                      binds)]
+    `(fn [~psym]
+       (let [~@forms]
+         [~val-expr ~psym]))))
+
+(defn- all-indexes
+  [state]
+  (let [r (map :index (:page-index state))]
+    (if (empty? r) '(0) r)))
+
+(defn- add-index
+  [state index moved?]
+  (update state :page-index conj {:index index :moved? moved?}))
+
+(defn- assign-page-index-op
+  [state]
+  (let [max-index (apply max (all-indexes state))
+          next-index (inc max-index)]
+      [next-index (add-index state next-index false)]))
+
+(defn- get-rand-available-index-op
+  [state]
+  (let [indexes (:page-index state)]
+    (if (empty? indexes)
+      [nil state]
+      (let [rand-index (rand-nth (vec indexes))]
+        [((juxt :index :moved?) rand-index) state]))))
+
+(defn- action-op
+  [action id & args]
+  (fn [state]
+    (let [generated-action (gen/generate (apply gen-action* action id args))
+          [moved?] args
+          state* (update state :generated-action conj generated-action)
+          state* (case action
+                   :move-file
+                   (update state* :page-index
+                           #(-> %
+                                (disj {:index id :moved? (boolean moved?)})
+                                (conj {:index id :moved? true})))
+                   :delete-file
+                   (update state* :page-index
+                           #(disj % {:index id :moved? (boolean moved?)}))
+                   state*)]
+      [nil state*])))
+
+(defmacro when-op
+  [x f]
+  `(fn [state#]
+     (if ~x
+       (~f state#)
+       [nil state#])))
+
+(defn- print-op
+  [x]
+  (fn [state]
+    (println x)
+    [nil state]))
+
+
+(defn rand-action-op
+  []
+  (let [action (gen/generate
+                (gen/frequency [[5 (gen/return :update-file)]
+                                [2 (gen/return :create-file)]
+                                [2 (gen/return :move-file)]
+                                [1 (gen/return :delete-file)]]))]
+    (case action
+      :create-file
+      (gen-actions-plan
+       [id assign-page-index-op
+        _ (action-op action id)]
+       nil)
+      :update-file
+      (gen-actions-plan
+       [id+moved? get-rand-available-index-op
+        _ (when-op id+moved? (apply action-op action id+moved?))]
+       nil)
+      :move-file
+      (gen-actions-plan
+       [id+moved? get-rand-available-index-op
+        _ (when-op id+moved? (apply action-op action id+moved?))]
+       nil)
+      :delete-file
+      (gen-actions-plan
+       [id+moved? get-rand-available-index-op
+        _ (when-op id+moved? (apply action-op action id+moved? ))]
+       nil))))
+
+(def empty-actions-plan {:page-index #{}
+                         :generated-action []})
+
+
+(defmacro generate-rand-actions
+  [max-n & {:keys [pre-create-files-n] :or {pre-create-files-n 2}}]
+  (let [pre-create-files-binds
+        (for [id (map (fn [_] (gensym)) (range pre-create-files-n))]
+          `[~id assign-page-index-op
+            ~'_ (action-op :create-file ~id)])
+        binds (apply concat
+                     (concat pre-create-files-binds (repeat max-n `[~'_ (rand-action-op)])))]
+    `(second
+      ((gen-actions-plan
+        ~binds
+        nil)
+       empty-actions-plan))))

+ 2 - 1
shadow-cljs.edn

@@ -33,7 +33,8 @@
                                                 :redef false}}
         :closure-defines  {goog.debug.LOGGING_ENABLED       true
                            frontend.config/ENABLE-FILE-SYNC #shadow/env ["ENABLE_FILE_SYNC" :as :bool :default false]
-                           frontend.config/ENABLE-PLUGINS   #shadow/env ["ENABLE_PLUGINS"   :as :bool :default true]}
+                           frontend.config/ENABLE-PLUGINS   #shadow/env ["ENABLE_PLUGINS"   :as :bool :default true]
+                           frontend.config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default false]}
 
         ;; NOTE: electron, browser/mobile-app use different asset-paths.
         ;;   For browser/mobile-app devs, assets are located in /static/js(via HTTP root).

+ 1 - 1
src/electron/electron/backup_file.cljs

@@ -5,7 +5,7 @@
             ["fs-extra" :as fs-extra]))
 
 (def backup-dir "logseq/bak")
-(def version-file-dir "version-files/local")
+(def version-file-dir "logseq/version-files/local")
 
 (defn- get-backup-dir*
   [repo relative-path bak-dir]

+ 20 - 2
src/electron/electron/file_sync_rsapi.cljs

@@ -1,7 +1,10 @@
 (ns electron.file-sync-rsapi
   (:require ["@logseq/rsapi" :as rsapi]))
 
-(defn set-env [env] (rsapi/setEnv env))
+(defn key-gen [] (rsapi/keygen))
+
+(defn set-env [env private-key public-key]
+  (rsapi/setEnv env private-key public-key))
 
 (defn get-local-files-meta [graph-uuid base-path file-paths]
   (rsapi/getLocalFilesMeta graph-uuid base-path (clj->js file-paths)))
@@ -18,6 +21,9 @@
 (defn update-local-files [graph-uuid base-path file-paths token]
   (rsapi/updateLocalFiles graph-uuid base-path (clj->js file-paths) token))
 
+(defn download-version-files [graph-uuid base-path file-paths token]
+  (rsapi/updateLocalVersionFiles 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))
 
@@ -25,4 +31,16 @@
   (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))
+  (rsapi/updateRemoteFiles graph-uuid base-path (clj->js file-paths) txid token true))
+
+(defn encrypt-fnames [fnames]
+  (mapv rsapi/encryptFname fnames))
+
+(defn decrypt-fnames [fnames]
+  (mapv rsapi/decryptFname fnames))
+
+(defn encrypt-with-passphrase [passphrase data]
+  (rsapi/ageEncryptWithPassphrase passphrase data))
+
+(defn decrypt-with-passphrase [passphrase data]
+  (rsapi/ageDecryptWithPassphrase passphrase data))

+ 46 - 0
src/electron/electron/handler.cljs

@@ -22,6 +22,7 @@
             [electron.window :as win]
             [electron.file-sync-rsapi :as rsapi]
             [electron.backup-file :as backup-file]
+            [cljs.reader :as reader]
             [electron.find-in-page :as find]))
 
 (defmulti handle (fn [_window args] (keyword (first args))))
@@ -211,6 +212,30 @@
 (defmethod handle :getGraphs [_window [_]]
   (get-graphs))
 
+(defn- read-txid-info!
+  [root]
+  (try
+    (let [txid-path (.join path root "logseq/graphs-txid.edn")]
+      (when (fs/existsSync txid-path)
+        (when-let [sync-meta (and (not (string/blank? root))
+                                  (.toString (.readFileSync fs txid-path)))]
+          (reader/read-string sync-meta))))
+    (catch js/Error _e
+      (js/console.debug "[read txid meta] #" root (.-message _e)))))
+
+(defmethod handle :inflateGraphsInfo [_win [_ graphs]]
+  (if (seq graphs)
+    (for [{:keys [root] :as graph} graphs]
+      (if-let [sync-meta (read-txid-info! root)]
+        (assoc graph
+               :sync-meta sync-meta
+               :GraphUUID (second sync-meta))
+        graph))
+    []))
+
+(defmethod handle :readGraphTxIdInfo [_win [_ root]]
+  (read-txid-info! root))
+
 (defn- get-graph-path
   [graph-name]
   (when graph-name
@@ -290,6 +315,9 @@
 (defmethod handle :openDialog [^js _window _messages]
   (open-dir-dialog))
 
+(defmethod handle :copyDirectory [^js _window [_ src dest opts]]
+  (fs-extra/copy src dest opts))
+
 (defmethod handle :getLogseqDotDirRoot []
   (utils/get-ls-dotdir-root))
 
@@ -490,6 +518,9 @@
 ;; file-sync-rs-apis ;;
 ;;;;;;;;;;;;;;;;;;;;;;;
 
+(defmethod handle :key-gen [_]
+  (rsapi/key-gen))
+
 (defmethod handle :set-env [_ args]
   (apply rsapi/set-env (rest args)))
 
@@ -508,6 +539,9 @@
 (defmethod handle :update-local-files [_ args]
   (apply rsapi/update-local-files (rest args)))
 
+(defmethod handle :download-version-files [_ args]
+  (apply rsapi/download-version-files (rest args)))
+
 (defmethod handle :delete-remote-files [_ args]
   (apply rsapi/delete-remote-files (rest args)))
 
@@ -517,6 +551,18 @@
 (defmethod handle :update-remote-files [_ args]
   (apply rsapi/update-remote-files (rest args)))
 
+(defmethod handle :decrypt-fnames [_ args]
+  (apply rsapi/decrypt-fnames (rest args)))
+
+(defmethod handle :encrypt-fnames [_ args]
+  (apply rsapi/encrypt-fnames (rest args)))
+
+(defmethod handle :encrypt-with-passphrase [_ args]
+  (apply rsapi/encrypt-with-passphrase (rest args)))
+
+(defmethod handle :decrypt-with-passphrase [_ args]
+  (apply rsapi/decrypt-with-passphrase (rest args)))
+
 (defmethod handle :default [args]
   (println "Error: no ipc handler for: " (bean/->js args)))
 

+ 2 - 1
src/main/electron/listener.cljs

@@ -45,7 +45,8 @@
                      (fn [data]
                        (let [{:keys [type payload]} (bean/->clj data)]
                          (watcher-handler/handle-changed! type payload)
-                         (sync/file-watch-handler type payload))))
+                         (when config/enable-file-sync?
+                           (sync/file-watch-handler type payload)))))
 
   (js/window.apis.on "notification"
                      (fn [data]

+ 69 - 4
src/main/frontend/components/block.cljs

@@ -71,6 +71,7 @@
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
+            [frontend.handler.file-sync :as file-sync]
             [clojure.set :as set]
             [shadow.loader :as loader]))
 
@@ -180,6 +181,67 @@
                 parts (remove #(string/blank? %) parts)]
             (string/join "/" (reverse parts))))))))
 
+(rum/defcs asset-loader
+  < rum/reactive
+  (rum/local nil ::exist?)
+  (rum/local false ::loading?)
+  {:will-mount  (fn [state]
+                  (let [src (first (:rum/args state))]
+                    (if (and (gp-config/local-protocol-asset? src)
+                             (file-sync/current-graph-sync-on?))
+                      (let [*exist? (::exist? state)
+                            asset-path (gp-config/remove-asset-protocol src)]
+                        (if (string/blank? asset-path)
+                          (reset! *exist? false)
+                          (-> (fs/file-exists? "" asset-path)
+                              (p/then
+                               (fn [exist?]
+                                 (reset! *exist? (boolean exist?))))))
+                        (assoc state ::asset-path asset-path ::asset-file? true))
+                      state)))
+   :will-update (fn [state]
+                  (let [src (first (:rum/args state))
+                        asset-file? (boolean (::asset-file? state))
+                        sync-on? (file-sync/current-graph-sync-on?)
+                        *loading? (::loading? state)
+                        *exist? (::exist? state)]
+                    (when (and sync-on? asset-file? (false? @*exist?))
+                      (let [sync-state (state/sub [:file-sync/sync-state (state/get-current-repo)])
+                            _downloading-files (:current-remote->local-files sync-state)
+                            contain-url? (and (seq _downloading-files)
+                                              (some #(string/ends-with? src %) _downloading-files))]
+                        (cond
+                          (and (not @*loading?) contain-url?)
+                          (reset! *loading? true)
+
+                          (and @*loading? (not contain-url?))
+                          (do
+                            (reset! *exist? true)
+                            (reset! *loading? false))))))
+                  state)}
+  [state src content-fn]
+  (let [_ (state/sub [:file-sync/sync-state (state/get-current-repo)])
+        exist? @(::exist? state)
+        loading? @(::loading? state)
+        asset-file? (::asset-file? state)
+        sync-enabled? (boolean (file-sync/current-graph-sync-on?))
+        ext (keyword (util/get-file-ext src))
+        img? (contains? (gp-config/img-formats) ext)
+        audio? (contains? config/audio-formats ext)
+        type (cond img? "image"
+                   audio? "audio"
+                   :else "asset")]
+
+    (if (not sync-enabled?)
+      (content-fn)
+      (if (and asset-file? (or loading? (nil? exist?)))
+        [:p.text-sm.opacity-50 (ui/loading (util/format "Syncing %s ..." type))]
+        (if (or (not asset-file?)
+                (and exist? (not loading?)))
+          (content-fn)
+          [:p.text-red-500.text-xs [:small.opacity-80
+                                    (util/format "%s not found!" (string/capitalize type))]])))))
+
 (defonce *resizing-image? (atom false))
 (rum/defcs resizable-image <
   (rum/local nil ::size)
@@ -292,10 +354,12 @@
 
         (cond
           (contains? config/audio-formats ext)
-          (audio-cp @src)
+          (asset-loader @src
+                        #(audio-cp @src))
 
           (contains? (gp-config/img-formats) ext)
-          (resizable-image config title @src metadata full_text true)
+          (asset-loader @src
+                        #(resizable-image config title @src metadata full_text true))
 
           (contains? (gp-config/text-formats) ext)
           [:a.asset-ref.is-plaintext {:href (rfe/href :file {:path path})
@@ -1644,8 +1708,7 @@
                            [(str class " checked") true])]
     (when class
       (ui/checkbox {:class class
-                    :style {:margin-top -2
-                            :margin-right 5}
+                    :style {:margin-right 5}
                     :checked checked?
                     :on-mouse-down (fn [e]
                                      (util/stop-propagation e))
@@ -2562,7 +2625,9 @@
 
       (when @*show-left-menu?
         (block-left-menu config block))
+
       (block-content-or-editor config block edit-input-id block-id heading-level edit?)
+
       (when @*show-right-menu?
         (block-right-menu config block edit?))]
 

+ 226 - 52
src/main/frontend/components/encryption.cljs

@@ -1,21 +1,24 @@
 (ns frontend.components.encryption
   (:require [clojure.string :as string]
             [frontend.context.i18n :refer [t]]
-            [frontend.encrypt :as e]
+            [frontend.encrypt :as encrypt]
             [frontend.handler.metadata :as metadata-handler]
             [frontend.handler.notification :as notification]
+            [frontend.fs.sync :as sync]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
+            [frontend.config :as config]
             [promesa.core :as p]
+            [cljs.core.async :as async]
             [rum.core :as rum]))
 
 (rum/defcs encryption-dialog-inner <
   (rum/local false ::reveal-secret-phrase?)
   [state repo-url close-fn]
   (let [reveal-secret-phrase? (get state ::reveal-secret-phrase?)
-        public-key (e/get-public-key repo-url)
-        private-key (e/get-secret-key repo-url)]
+        public-key (encrypt/get-public-key repo-url)
+        private-key (encrypt/get-secret-key repo-url)]
     [:div
      [:div.sm:flex.sm:items-start
       [:div.mt-3.text-center.sm:mt-0.sm:text-left
@@ -48,58 +51,227 @@
   (fn [close-fn]
     (encryption-dialog-inner repo-url close-fn)))
 
-(rum/defcs input-password-inner <
+(rum/defcs ^:large-vars/cleanup-todo input-password-inner < rum/reactive
   (rum/local "" ::password)
-  (rum/local "" ::password-confirm)
-  [state repo-url close-fn]
-  (let [password (get state ::password)
-        password-confirm (get state ::password-confirm)]
-    [:div
-     [:div.sm:flex.sm:items-start
+  (rum/local "" ::pw-confirm)
+  (rum/local false ::pw-confirm-focused?)
+  {:will-mount (fn [state]
+                 ;; try to close tour tips
+                 (some->> (state/sub :file-sync/jstour-inst)
+                          (.complete))
+                 state)}
+  [state repo-url close-fn {:keys [type GraphName GraphUUID init-graph-keys after-input-password]}]
+  (let [*password (get state ::password)
+        *pw-confirm (get state ::pw-confirm)
+        *pw-confirm-focused? (get state ::pw-confirm-focused?)
+        *input-ref-0 (rum/create-ref)
+        *input-ref-1 (rum/create-ref)
+        remote-pw? (= type :input-pwd-remote)
+        loading? (state/sub [:ui/loading? :set-graph-password])
+        pw-strength (when (and init-graph-keys
+                               (not (string/blank? @*password)))
+                      (util/check-password-strength @*password))
+        can-submit? #(if init-graph-keys
+                       (and (>= (count @*password) 6)
+                            (>= (:id pw-strength) 1))
+                       true)
+        set-remote-graph-pwd-result (state/sub [:file-sync/set-remote-graph-password-result])
+
+        submit-handler
+        (fn []
+          (let [value @*password]
+            (cond
+              (string/blank? value)
+              nil
+
+              (and init-graph-keys (not= @*password @*pw-confirm))
+              (notification/show! "The passwords are not matched." :error)
+
+              :else
+              (case type
+                :local
+                (p/let [keys                (encrypt/generate-key-pair-and-save! repo-url)
+                        db-encrypted-secret (encrypt/encrypt-with-passphrase value keys)]
+                  (metadata-handler/set-db-encrypted-secret! db-encrypted-secret)
+                  (close-fn true))
+
+                (:create-pwd-remote :input-pwd-remote)
+                (do
+                  (state/set-state! [:ui/loading? :set-graph-password] true)
+                  (state/set-state! [:file-sync/set-remote-graph-password-result] {})
+                  (async/go
+                    (let [persist-r (async/<! (sync/encrypt+persist-pwd! @*password GraphUUID))]
+                      (if (instance? js/Error persist-r)
+                        (js/console.error persist-r)
+                        (when (fn? after-input-password)
+                          (async/<! (after-input-password))
+                          ;; TODO: it's better if based on sync state
+                          (when init-graph-keys
+                            (js/setTimeout #(state/pub-event! [:file-sync/maybe-onboarding-show :sync-learn]) 10000)))))))))))
+
+        cancel-handler
+        (fn []
+          (state/set-state! [:file-sync/set-remote-graph-password-result] {})
+          (close-fn))
+
+        enter-handler
+        (fn [^js e]
+          (when-let [^js input (and e (= 13 (.-which e)) (.-target e))]
+            (when-not (string/blank? (.-value input))
+              (let [input-0? (= (util/safe-lower-case (.-placeholder input)) "password")]
+                (if init-graph-keys
+                  ;; setup mode
+                  (if input-0?
+                    (.select (rum/deref *input-ref-1))
+                    (submit-handler))
+
+                  ;; unlock mode
+                  (submit-handler))))))]
+
+    [:div.encryption-password.max-w-2xl.-mb-2
+     [:div.cp__file-sync-related-normal-modal
+      [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "lock-access")]]
+
       [:div.mt-3.text-center.sm:mt-0.sm:text-left
-       [:h3#modal-headline.text-lg.leading-6.font-medium.font-bold
-        "Enter a password"]]]
+       [:h1#modal-headline.text-2xl.font-bold.text-center
+        (if init-graph-keys
+          (if remote-pw?
+            "Secure this remote graph!"
+            "Encrypt this graph")
+          (if remote-pw?
+            "Unlock this remote graph!"
+            "Decrypt this graph"))]]
 
-     (ui/admonition
-      :warning
-      [:div.opacity-70
-       "Choose a strong and hard to guess password.\nIf you lose your password, all the data can't be decrypted!! Please make sure you remember the password you have set, or you can keep a secure backup of the password."])
-     [:input.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
-      {:type "password"
-       :placeholder "Password"
-       :auto-focus true
-       :on-change (fn [e]
-                    (reset! password (util/evalue e)))}]
-     [:input.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
-      {:type "password"
-       :placeholder "Re-enter the password"
-       :on-change (fn [e]
-                    (reset! password-confirm (util/evalue e)))}]
+      ;; decrypt remote graph with one password
+      (when (and remote-pw? (not init-graph-keys))
+        [:<>
 
-     [:div.mt-5.sm:mt-4.sm:flex.sm:flex-row-reverse
-      [:span.flex.w-full.rounded-md.shadow-sm.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 (fn []
-                     (let [value @password]
-                       (cond
-                         (string/blank? value)
-                         nil
-
-                         (not= @password @password-confirm)
-                         (notification/show! "The passwords are not matched." :error)
-
-                         :else
-                         (p/let [keys (e/generate-key-pair-and-save! repo-url)
-                                 db-encrypted-secret (e/encrypt-with-passphrase value keys)]
-                           (metadata-handler/set-db-encrypted-secret! db-encrypted-secret)
-                           (close-fn true)))))}
-        "Submit"]]]]))
+         [:div.folder-tip.flex.flex-col.items-center
+          [:h3
+           [:span.flex.space-x-2.leading-none.pb-1
+            (ui/icon "cloud-lock")
+            [:span GraphName]
+            [:span.scale-75 (ui/icon "arrow-right")]
+            [:span (ui/icon "folder")]]]
+          [:h4.px-2.-mb-1.5 (config/get-string-repo-dir repo-url)]]
+
+         [:div.input-hints.text-sm.py-2.px-3.rounded.mb-2.mt-2.flex.items-center
+          (if-let [display-str (:fail set-remote-graph-pwd-result)]
+            [:<>
+             [:span.scale-125.pr-1.text-red-600 (ui/icon "alert-circle" {:class "text-md mr-1"})]
+             [:span.text-red-600 display-str]]
+            [:<>
+             [:span.scale-125.pr-1 (ui/icon "bulb" {:class "text-md mr-1"})]
+             [:span "Please enter the password for this graph to continue syncing."]])]])
+
+      ;; secure this remote graph
+      (when (and remote-pw? init-graph-keys)
+        (let [pattern-ok? #(>= (count @*password) 6)]
+          [:<>
+           [:h2.text-center.opacity-70.text-sm.py-2
+            "Each graph you want to synchronize via Logseq needs its own password for end-to-end encryption."]
+           [:div.input-hints.text-sm.py-2.px-3.rounded.mb-3.mt-4.flex.items-center
+            (if (or (not (string/blank? @*password))
+                    (not (string/blank? @*pw-confirm)))
+              (if (or (not (pattern-ok?))
+                      (not= @*password @*pw-confirm))
+                [:span.scale-125.pr-1.text-red-600 (ui/icon "alert-circle" {:class "text-md mr-1"})]
+                [:span.scale-125.pr-1.text-green-600 (ui/icon "circle-check" {:class "text-md mr-1"})])
+              [:span.scale-125.pr-1 (ui/icon "bulb" {:class "text-md mr-1"})])
+
+            (if (not (string/blank? @*password))
+              (if-not (pattern-ok?)
+                [:span "Password can't be less than 6 characters"]
+                (if (not (string/blank? @*pw-confirm))
+                  (if (not= @*pw-confirm @*password)
+                    [:span "Password fields are not matching!"]
+                    [:span "Password fields are matching!"])
+                  [:span "Enter your chosen password again!"]))
+              [:span "Choose a strong and hard to guess password!"])
+            ]
+
+           ;; password strength checker
+           (when-not (string/blank? @*password)
+             [:<>
+              [:div.input-hints.text-sm.py-2.px-3.rounded.mb-2.-mt-1.5.flex.items-center.space-x-3
+               (let [included-set (set (:contains pw-strength))]
+                 (for [i ["lowercase" "uppercase" "number" "symbol"]
+                       :let [included? (contains? included-set i)]]
+                   [:span.strength-item
+                    {:key i
+                     :class (when included? "included")}
+                    (ui/icon (if included? "check" "x") {:class "mr-1"})
+                    [:span.capitalize i]
+                    ]))]
+
+              [:div.input-pw-strength
+               [:div.indicator.flex
+                (for [i (range 4)
+                      :let [title (get ["Too weak" "Weak" "Medium" "Strong"] i)]]
+                  [:i {:key i
+                       :title title
+                       :class (when (>= (int (:id pw-strength)) i) "active")} i])]]])]))
+
+      [:input.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
+       {:type        "password"
+        :ref         *input-ref-0
+        :placeholder "Password"
+        :auto-focus  true
+        :disabled    loading?
+        :on-key-up   enter-handler
+        :on-change   (fn [^js e]
+                       (reset! *password (util/evalue e))
+                       (when (:fail set-remote-graph-pwd-result)
+                         (state/set-state! [:file-sync/set-remote-graph-password-result] {})))}]
+
+      (when init-graph-keys
+        [:input.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
+         {:type        "password"
+          :ref         *input-ref-1
+          :placeholder "Re-enter the password"
+          :on-focus    #(reset! *pw-confirm-focused? true)
+          :on-blur     #(reset! *pw-confirm-focused? false)
+          :disabled    loading?
+          :on-key-up   enter-handler
+          :on-change   (fn [^js e]
+                         (reset! *pw-confirm (util/evalue e)))}])
+
+      (when init-graph-keys
+        [:div.init-remote-pw-tips.space-x-4.pt-2.hidden.sm:flex
+         [:div.flex-1.flex.items-center
+          [:span.px-3.scale-125 (ui/icon "key")]
+          [:p.dark:text-gray-100
+           [:span "Please make sure you "]
+           "remember the password you have set, "
+           [:span "and we recommend you "]
+           "keep a secure backup "
+           [:span "of the password."]]]
+
+         [:div.flex-1.flex.items-center
+          [:span.px-3.scale-125 (ui/icon "lock")]
+          [:p.dark:text-gray-100
+           "If you lose your password, all of your data in the cloud can’t be decrypted. "
+           [:span "You will still be able to access the local version of your graph."]]]])]
+
+     [:div.mt-5.sm:mt-4.flex.justify-center.sm:justify-end.space-x-3
+      (ui/button (t :cancel) :background "gray" :disabled loading? :class "opacity-60" :on-click cancel-handler)
+      (ui/button [:span.inline-flex.items-center.leading-none
+                  [:span (t :submit)]
+                  (when loading?
+                    [:span.ml-1 (ui/loading "" {:class "w-4 h-4"})])]
+
+                 :disabled (or (not (can-submit?)) loading?)
+                 :on-click submit-handler)]]))
 
 (defn input-password
-  [repo-url close-fn]
-  (fn [_close-fn]
-    (input-password-inner repo-url close-fn)))
+  ([repo-url close-fn] (input-password repo-url close-fn {:type :local}))
+  ([repo-url close-fn opts]
+   (fn [_close-fn]
+     (let [close-fn' (if (fn? close-fn)
+                       #(do (close-fn %)
+                            (_close-fn))
+                       _close-fn)]
+       (input-password-inner repo-url close-fn' opts)))))
 
 (rum/defcs encryption-setup-dialog-inner
   [state repo-url close-fn]
@@ -114,7 +286,9 @@
      [: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 (fn []
-                   (state/set-modal! (input-password repo-url close-fn)))}
+                   (state/set-modal!
+                    (input-password repo-url close-fn)
+                    {:center? true :close-btn? false}))}
       (t :yes)]]
     [:span.mt-3.flex.w-full.rounded-md.shadow-sm.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
@@ -142,9 +316,9 @@
                         (when-not (string/blank? value) ; TODO: length or other checks
                           (let [repo (state/get-current-repo)]
                             (p/do!
-                             (-> (e/decrypt-with-passphrase value db-encrypted-secret)
+                             (-> (encrypt/decrypt-with-passphrase value db-encrypted-secret)
                                  (p/then (fn [keys]
-                                           (e/save-key-pair! repo keys)
+                                           (encrypt/save-key-pair! repo keys)
                                            (close-fn true)
                                            (state/set-state! :encryption/graph-parsing? false)))
                                  (p/catch #(notification/show! "The password is not matched." :warning true))

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

@@ -100,7 +100,7 @@
      (cond
        ;; image type
        (and format (contains? (gp-config/img-formats) format))
-       [:img {:src path}]
+       [:img {:src (util/node-path.join "file://" path)}]
 
        (and format (contains? (gp-config/text-formats) format))
        (when-let [file-content (or (db/get-file path) "")]

+ 674 - 0
src/main/frontend/components/file_sync.cljs

@@ -0,0 +1,674 @@
+(ns frontend.components.file-sync
+  (:require [cljs.core.async :as async]
+            [clojure.string :as string]
+            [electron.ipc :as ipc]
+            [frontend.components.lazy-editor :as lazy-editor]
+            [frontend.components.onboarding.quick-tour :as quick-tour]
+            [frontend.components.page :as page]
+            [frontend.config :as config]
+            [frontend.db :as db]
+            [frontend.db.model :as db-model]
+            [frontend.fs :as fs]
+            [frontend.fs.sync :as fs-sync]
+            [frontend.handler.file-sync :refer [*beta-unavailable?] :as file-sync-handler]
+            [frontend.handler.notification :as notifications]
+            [frontend.handler.page :as page-handler]
+            [frontend.handler.repo :as repo-handler]
+            [frontend.handler.user :as user-handler]
+            [frontend.handler.web.nfs :as web-nfs]
+            [frontend.mobile.util :as mobile-util]
+            [frontend.state :as state]
+            [frontend.ui :as ui]
+            [frontend.util :as util]
+            [frontend.util.fs :as fs-util]
+            [logseq.graph-parser.config :as gp-config]
+            [promesa.core :as p]
+            [reitit.frontend.easy :as rfe]
+            [rum.core :as rum]))
+
+(declare maybe-onboarding-show)
+(declare open-icloud-graph-clone-picker)
+
+(rum/defc clone-local-icloud-graph-panel
+  [repo graph-name close-fn]
+
+  (rum/use-effect!
+   #(some->> (state/sub :file-sync/jstour-inst)
+             (.complete))
+   [])
+
+  (let [graph-dir      (config/get-repo-dir repo)
+        [selected-path set-selected-path] (rum/use-state "")
+        selected-path? (and (not (string/blank? selected-path))
+                            (not (mobile-util/iCloud-container-path? selected-path)))
+        on-confirm     (fn []
+                         (when-let [dest-dir (and selected-path?
+                                                  ;; avoid using `util/node-path.join` to join mobile path since it replaces `file:///abc` to `file:/abc`
+                                                  (str (string/replace selected-path #"/+$" "") "/" graph-name))]
+                           (-> (cond
+                                 (util/electron?)
+                                 (ipc/ipc :copyDirectory graph-dir dest-dir)
+
+                                 (mobile-util/native-ios?)
+                                 (fs/copy! repo graph-dir dest-dir)
+
+                                 :else
+                                 nil)
+                               (.then #(do
+                                         (notifications/show! (str "Cloned to => " dest-dir) :success)
+                                         (web-nfs/ls-dir-files-with-path! dest-dir)
+                                         (repo-handler/remove-repo! {:url repo})
+                                         (close-fn)))
+                               (.catch #(js/console.error %)))))]
+
+    [:div.cp__file-sync-related-normal-modal
+     [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "folders")]]
+
+     [:h1.text-xl.font-semibold.opacity-90.text-center.py-2
+      "Clone your local graph away from " [:strong "☁️"] " iCloud!"]
+     [:h2.text-center.opacity-70.text-xs.leading-5
+      "Unfortunately, Logseq Sync and iCloud don't work perfectly together at the moment. To make sure"
+      [:br]
+      "You can always delete the remote graph at a later point."]
+
+     [:div.folder-tip.flex.flex-col.items-center
+      [:h3
+       [:span (ui/icon "folder") [:label.pl-0.5 (js/decodeURIComponent graph-name)]]]
+      [:h4.px-6 (config/get-string-repo-dir repo)]
+
+      (when (not (string/blank? selected-path))
+        [:h5.text-xs.pt-1.-mb-1.flex.items-center.leading-none
+         (if (mobile-util/iCloud-container-path? selected-path)
+           [:span.inline-block.pr-1.text-red-600.scale-75 (ui/icon "alert-circle")]
+           [:span.inline-block.pr-1.text-green-600.scale-75 (ui/icon "circle-check")])
+         selected-path])
+
+      [:div.out-icloud
+       (ui/button
+        [:span.inline-flex.items-center.leading-none.opacity-90
+         "Select new parent folder outside of iCloud" (ui/icon "arrow-right")]
+
+        :on-click
+        (fn []
+          ;; TODO: support mobile
+          (cond
+            (util/electron?)
+            (p/let [path (ipc/ipc "openDialog")]
+              (set-selected-path path))
+
+            (mobile-util/native-ios?)
+            (p/let [{:keys [path _localDocumentsPath]}
+                    (p/chain
+                     (.pickFolder mobile-util/folder-picker)
+                     #(js->clj % :keywordize-keys true))]
+              (set-selected-path path))
+
+            :else
+            nil)))]]
+
+     [:p.flex.items-center.space-x-2.pt-6.flex.justify-center.sm:justify-end.-mb-2
+      (ui/button "Cancel" :background "gray" :class "opacity-50" :on-click close-fn)
+      (ui/button "Clone graph" :disabled (not selected-path?) :on-click on-confirm)]]))
+
+(rum/defc create-remote-graph-panel
+  [repo graph-name close-fn]
+
+  (rum/use-effect!
+   #(some->> (state/sub :file-sync/jstour-inst)
+             (.complete))
+   [])
+
+  (let [on-confirm
+        (fn []
+          (async/go
+            (close-fn)
+            (if (mobile-util/iCloud-container-path? repo)
+              (open-icloud-graph-clone-picker repo)
+              (do
+                (state/set-state! [:ui/loading? :graph/create-remote?] true)
+                (when-let [GraphUUID (get (async/<! (file-sync-handler/create-graph graph-name)) 2)]
+                  (async/<! (fs-sync/sync-start))
+                  (state/set-state! [:ui/loading? :graph/create-remote?] false)
+                 ;; update existing repo
+                 (state/set-repos! (map (fn [r]
+                                          (if (= (:url r) repo)
+                                            (assoc r
+                                                   :GraphUUID GraphUUID
+                                                   :GraphName graph-name
+                                                   :remote? true)
+                                            r))
+                                     (state/get-repos))))))))]
+
+    [:div.cp__file-sync-related-normal-modal
+     [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "cloud-upload")]]
+
+     [:h1.text-xl.font-semibold.opacity-90.text-center.py-2
+      "Are you sure you want to create a new remote graph?"]
+     [:h2.text-center.opacity-70.text-xs
+      "By continuing this action you will create an encrypted cloud version of your current local graph." [:br]
+      "You can always delete the remote graph at a later point."]
+
+     [:div.folder-tip.flex.flex-col.items-center
+      [:h3
+       [:span (ui/icon "folder") [:label.pl-0.5 graph-name]]
+       [:span.opacity-50.scale-75 (ui/icon "arrow-right")]
+       [:span (ui/icon "cloud-lock")]]
+      [:h4.px-4 (config/get-string-repo-dir repo)]]
+
+     [:p.flex.items-center.space-x-2.pt-6.flex.justify-center.sm:justify-end.-mb-2
+      (ui/button "Cancel" :background "gray" :class "opacity-50" :on-click close-fn)
+      (ui/button "Create remote graph" :on-click on-confirm)]]))
+
+(rum/defcs ^:large-vars/cleanup-todo indicator <
+  rum/reactive
+  {:will-mount   (fn [state]
+                   (let [unsub-fn (file-sync-handler/setup-file-sync-event-listeners)]
+                     (assoc state ::unsub-events unsub-fn)))
+   :will-unmount (fn [state]
+                   (apply (::unsub-events state) nil)
+                   state)}
+  [_state]
+  (let [_                      (state/sub :auth/id-token)
+        current-repo           (state/get-current-repo)
+        creating-remote-graph? (state/sub [:ui/loading? :graph/create-remote?])
+        sync-state             (state/sub [:file-sync/sync-state current-repo])
+        _                      (rum/react file-sync-handler/refresh-file-sync-component)
+        synced-file-graph?     (file-sync-handler/synced-file-graph? current-repo)
+        uploading-files        (:current-local->remote-files sync-state)
+        downloading-files      (:current-remote->local-files sync-state)
+        queuing-files          (:queued-local->remote-files sync-state)
+
+        status                 (:state sync-state)
+        status                 (or (nil? status) (keyword (name status)))
+        off?                   (or (nil? sync-state) (fs-sync/sync-state--stopped? sync-state))
+        full-syncing?          (contains? #{:local->remote-full-sync :remote->local-full-sync} status)
+        syncing?               (or full-syncing? (contains? #{:local->remote :remote->local} status))
+        idle?                  (contains? #{:idle} status)
+        need-password?         (contains? #{:need-password} status)
+        queuing?               (and idle? (boolean (seq queuing-files)))
+        no-active-files?       (empty? (concat downloading-files queuing-files uploading-files))
+        create-remote-graph-fn #(when (and current-repo (not (config/demo-graph? current-repo)))
+                                  (let [graph-name
+                                        (js/decodeURI (util/node-path.basename current-repo))
+
+                                        confirm-fn
+                                        (fn [close-fn]
+                                          (create-remote-graph-panel current-repo graph-name close-fn))]
+
+                                    (state/set-modal! confirm-fn {:center? true :close-btn? false})))
+        turn-on                #(async/go
+                                  (cond
+                                    @*beta-unavailable?
+                                    (state/pub-event! [:file-sync/onboarding-tip :unavailable])
+
+                                    ;; current graph belong to other user, do nothing
+                                    (and (first @fs-sync/graphs-txid)
+                                         (not (fs-sync/check-graph-belong-to-current-user (user-handler/user-uuid)
+                                                                                          (first @fs-sync/graphs-txid))))
+                                    nil
+
+                                    (and synced-file-graph?
+                                         (async/<! (fs-sync/<check-remote-graph-exists (second @fs-sync/graphs-txid))))
+                                    (fs-sync/sync-start)
+
+
+                                    ;; remote graph already has been deleted, clear repos first, then create-remote-graph
+                                    synced-file-graph?      ; <check-remote-graph-exists -> false
+                                    (do (state/set-repos!
+                                         (map (fn [r]
+                                                (if (= (:url r) current-repo)
+                                                  (dissoc r :GraphUUID :GraphName :remote?)
+                                                  r))
+                                              (state/get-repos)))
+                                        (create-remote-graph-fn))
+
+                                    :else
+                                    (create-remote-graph-fn)))]
+
+    (if creating-remote-graph?
+      (ui/loading "")
+      [:div.cp__file-sync-indicator
+       (when (and (not config/publishing?)
+                  (user-handler/logged-in?))
+
+         (ui/dropdown-with-links
+          (fn [{:keys [toggle-fn]}]
+            (if (not off?)
+              [:a.button.cloud.on
+               {:on-click toggle-fn
+                :class    (util/classnames [{:syncing syncing?
+                                             :is-full full-syncing?
+                                             :queuing queuing?
+                                             :idle    (and (not queuing?) idle?)}])}
+               [:span.flex.items-center
+                (ui/icon "cloud"
+                         {:style {:fontSize ui/icon-size}})]]
+
+              [:a.button.cloud.off
+               {:on-click turn-on}
+               (ui/icon "cloud-off" {:style {:fontSize ui/icon-size}})]))
+
+          (cond-> []
+            synced-file-graph?
+            (concat
+             (if (and no-active-files? idle?)
+               [{:item [:div.flex.justify-center.w-full.py-2
+                        [:span.opacity-60 "Everything is synced!"]]
+                 :as-link? false}]
+               (if need-password?
+                 [{:title   [:div.file-item
+                             (ui/icon "lock") "Password is required"]
+                   :options {:on-click #(state/pub-event! [:file-sync/restart])}}]
+                 [{:title   [:div.file-item.is-first ""]
+                   :options {:class "is-first-placeholder"}}]))
+
+             (map (fn [f] {:title [:div.file-item
+                                   {:key (str "downloading-" f)}
+                                   (js/decodeURIComponent f)]
+                           :key   (str "downloading-" f)
+                           :icon  (ui/icon "arrow-narrow-down")}) downloading-files)
+             (map (fn [e] (let [icon (case (.-type e)
+                                       "add"    "plus"
+                                       "unlink" "minus"
+                                       "edit")
+                                path (fs-sync/relative-path e)]
+                            {:title [:div.file-item
+                                     {:key (str "queue-" path)}
+                                     (js/decodeURIComponent path)]
+                             :key   (str "queue-" path)
+                             :icon  (ui/icon icon)})) (take 10 queuing-files))
+             (map (fn [f] {:title [:div.file-item
+                                   {:key (str "uploading-" f)}
+                                   (js/decodeURIComponent f)]
+                           :key   (str "uploading-" f)
+                           :icon  (ui/icon "arrow-up")}) uploading-files)
+
+             (when sync-state
+               (map-indexed (fn [i f] (:time f)
+                              (let [path       (:path f)
+                                    ext        (string/lower-case (util/get-file-ext path))
+                                    _supported? (gp-config/mldoc-support? ext)
+                                    full-path  (util/node-path.join (config/get-repo-dir current-repo) path)
+                                    page-name  (db/get-file-page full-path)]
+                                {:title [:div.files-history.cursor-pointer
+                                         {:key i :class (when (= i 0) "is-first")
+                                          :on-click (fn []
+                                                      (if page-name
+                                                        (rfe/push-state :page {:name page-name})
+                                                        (rfe/push-state :file {:path full-path})))}
+                                         [:span.file-sync-item (js/decodeURIComponent (:path f))]
+                                         [:div.opacity-50 (ui/humanity-time-ago (:time f) nil)]]}))
+                            (take 10 (:history sync-state))))))
+
+          {:links-header
+           [:<>
+            (when (and synced-file-graph? queuing?)
+              [:div.head-ctls
+               (ui/button "Sync now"
+                 :class "block cursor-pointer"
+                 :small? true
+                 :on-click #(async/offer! fs-sync/immediately-local->remote-chan true))])
+
+                                        ;(when config/dev?
+                                        ;  [:strong.debug-status (str status)])
+            ]}))])))
+
+(rum/defc pick-local-graph-for-sync [graph]
+  (rum/use-effect!
+   (fn []
+     (file-sync-handler/set-wait-syncing-graph graph)
+     #(file-sync-handler/set-wait-syncing-graph nil))
+   [graph])
+
+  [:div.cp__file-sync-related-normal-modal
+   [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "cloud-download")]]
+
+   [:h1.mb-5.text-2xl.text-center.font-bold "Sync a remote graph to local"]
+
+   [:div.folder-tip.flex.flex-col.items-center
+    {:style {:border-bottom-right-radius 0 :border-bottom-left-radius 0}}
+    [:h3
+     [:span.flex.space-x-2.leading-none.pb-1
+      (ui/icon "cloud-lock")
+      [:span (:GraphName graph)]
+      [:span.scale-75 (ui/icon "arrow-right")]
+      [:span (ui/icon "folder")]]]
+    [:h4.px-2.-mb-1.5 [:strong "UUID: "] (:GraphUUID graph)]]
+
+   [:div.-mt-1
+    (ui/button
+     (str "Open a local directory")
+     :class "w-full rounded-t-none py-4"
+     :on-click #(-> (page-handler/ls-dir-files!
+                     (fn [{:keys [url]}]
+                       (file-sync-handler/init-remote-graph url)
+                       ;; TODO: wait for switch done
+                       (js/setTimeout (fn [] (repo-handler/refresh-repos!)) 200))
+
+                     {:empty-dir?-or-pred
+                      (fn [ret]
+                        (let [empty-dir? (nil? (second ret))]
+                          (if-let [root (first ret)]
+
+                            ;; verify directory
+                            (-> (if empty-dir?
+                                  (p/resolved nil)
+                                  (if (util/electron?)
+                                    (ipc/ipc :readGraphTxIdInfo root)
+                                    (fs-util/read-graph-txid-info root)))
+
+                                (p/then (fn [^js info]
+                                          (when (and (not empty-dir?)
+                                                     (or (nil? info)
+                                                         (nil? (second info))
+                                                         (not= (second info) (:GraphUUID graph))))
+                                            (throw (js/Error. "AssertDirectoryError"))))))
+
+                            ;; cancel pick a directory
+                            (throw (js/Error. nil)))))})
+
+                    (p/catch (fn [^js e]
+                               (when (= "AssertDirectoryError" (.-message e))
+                                 (notifications/show! "Please select an empty directory or an existing remote graph!" :error))))))
+    [:p.text-xs.opacity-50.px-1 (ui/icon "alert-circle") " An empty directory or an existing remote graph!"]]])
+
+(defn pick-dest-to-sync-panel [graph]
+  (fn []
+    (pick-local-graph-for-sync graph)))
+
+(rum/defc page-history-list
+  [graph-uuid page-entity set-list-ready? set-page]
+
+  (let [[version-files set-version-files] (rum/use-state nil)
+        [current-page set-current-page] (rum/use-state nil)
+        [loading? set-loading?] (rum/use-state false)
+
+        set-page-fn     (fn [page-meta]
+                          (set-current-page page-meta)
+                          (set-page page-meta))
+
+        get-version-key #(or (:VersionUUID %) (:relative-path %))]
+
+    ;; fetch version files
+    (rum/use-effect!
+     (fn []
+       (when-not loading?
+         (async/go
+           (set-loading? true)
+           (try
+             (let [files (async/<! (file-sync-handler/fetch-page-file-versions graph-uuid page-entity))]
+               (set-version-files files)
+               (set-page-fn (first files))
+               (set-list-ready? true))
+             (finally (set-loading? false)))))
+       #())
+     [])
+
+    [:div.version-list
+     (if loading?
+       [:div.p-4 (ui/loading "Loading...")]
+       (for [version version-files]
+         (let [version-uuid (get-version-key version)
+               _local?      (some? (:relative-path version))]
+           [:div.version-list-item {:key version-uuid}
+            [:a.item-link.block.fade-link.flex.justify-between
+             {:title    version-uuid
+              :class    (util/classnames
+                         [{:active (and current-page (= version-uuid (get-version-key current-page)))}])
+              :on-click #(set-page-fn version)}
+
+             [:div.text-sm.pt-1
+              (ui/humanity-time-ago
+               (or (:CreateTime version)
+                   (:create-time version)) nil)]
+             [:small.opacity-50.translate-y-1
+              (if _local?
+                [:<> (ui/icon "git-commit") " local"]
+                [:<> (ui/icon "cloud") " remote"])]]])))]))
+
+(rum/defc pick-page-histories-for-sync
+  [repo-url graph-uuid page-name page-entity]
+  (let [[selected-page set-selected-page] (rum/use-state nil)
+        get-version-key    #(or (:VersionUUID %) (:relative-path %))
+        file-uuid          (:FileUUID selected-page)
+        version-uuid       (:VersionUUID selected-page)
+        [version-content set-version-content] (rum/use-state nil)
+        [list-ready? set-list-ready?] (rum/use-state false)
+        [content-ready? set-content-ready?] (rum/use-state false)
+        *ref-contents      (rum/use-ref (atom {}))
+        original-page-name (or (:block/original-name page-entity) page-name)]
+
+    (rum/use-effect!
+     #(when selected-page
+        (set-content-ready? false)
+        (let [k               (get-version-key selected-page)
+              loaded-contents @(rum/deref *ref-contents)]
+          (if (contains? loaded-contents k)
+            (do
+              (set-version-content (get loaded-contents k))
+              (js/setTimeout (fn [] (set-content-ready? true)) 100))
+
+            ;; without cache
+            (let [load-file (fn [repo-url file]
+                              (-> (fs-util/read-repo-file repo-url file)
+                                  (p/then
+                                   (fn [content]
+                                     (set-version-content content)
+                                     (set-content-ready? true)
+                                     (swap! (rum/deref *ref-contents) assoc k content)))))]
+              (if (and file-uuid version-uuid)
+                ;; read remote content
+                (async/go
+                  (let [downloaded-path (async/<! (file-sync-handler/download-version-file graph-uuid file-uuid version-uuid true))]
+                    (when downloaded-path
+                      (load-file repo-url downloaded-path))))
+
+                ;; read local content
+                (when-let [relative-path (:relative-path selected-page)]
+                  (load-file repo-url relative-path)))))))
+     [selected-page])
+
+    (rum/use-effect!
+     (fn []
+       (state/update-state! :editor/hidden-editors #(conj % page-name))
+
+       ;; clear effect
+       (fn []
+         (state/update-state! :editor/hidden-editors #(disj % page-name))))
+     [page-name])
+
+    [:div.cp__file-sync-page-histories.flex-wrap
+     {:class (util/classnames [{:is-list-ready list-ready?}])}
+
+     [:h1.absolute.top-0.left-0.text-xl.px-4.py-4.leading-4
+      (ui/icon "history")
+      " History for page "
+      [:span.font-medium original-page-name]]
+
+     ;; history versions
+     [:div.cp__file-sync-page-histories-left.flex-wrap
+      ;; sidebar lists
+      (page-history-list graph-uuid page-entity set-list-ready? set-selected-page)
+
+      ;; content detail
+      [:article
+       (when-let [inst-id (and selected-page (get-version-key selected-page))]
+         (if content-ready?
+           [:div.relative.raw-content-editor
+            (lazy-editor/editor
+             nil inst-id {:data-lang "markdown"}
+             version-content {:lineWrapping true :readOnly true :lineNumbers true})
+            [:div.absolute.top-1.right-1.opacity-50.hover:opacity-100
+             (ui/button "Restore"
+                        :small? true
+                        :on-click #(state/pub-event! [:file-sync-graph/restore-file (state/get-current-repo) page-entity version-content]))]]
+           [:span.flex.p-15.items-center.justify-center (ui/loading "")]))]]
+
+     ;; current version
+     [:div.cp__file-sync-page-histories-right
+      [:h1.title.text-xl
+       "Current version"]
+      (page/page-blocks-cp (state/get-current-repo) page-entity nil)]
+
+     ;; ready loading
+     [:div.flex.items-center.h-full.justify-center.w-full.absolute.ready-loading
+      (ui/loading "Loading...")]]))
+
+(defn pick-page-histories-panel [graph-uuid page-name]
+  (fn []
+    (if-let [page-entity (db-model/get-page page-name)]
+      (pick-page-histories-for-sync (state/get-current-repo) graph-uuid page-name page-entity)
+      (ui/admonition :warning (str "The page (" page-name ") does not exist!")))))
+
+(rum/defc onboarding-welcome-logseq-sync
+  [close-fn]
+
+  (let [[loading? set-loading?] (rum/use-state false)]
+    [:div.cp__file-sync-welcome-logseq-sync
+     [:span.head-bg
+
+      [:strong "CLOSED BETA"]]
+
+     [:h1.text-2xl.font-bold.flex-col.sm:flex-row
+      [:span.opacity-80 "Welcome to "]
+      [:span.pl-2.dark:text-white.text-gray-800 "Logseq Sync! 👋"]]
+
+     [:h2
+      "No more cloud storage worries. With Logseq's encrypted file syncing, "
+      [:br]
+      "you'll always have your notes backed up and available in real-time on any device."]
+
+     [:div.pt-6.flex.justify-center.space-x-2.sm:justify-end
+      (ui/button "Later" :on-click close-fn :background "gray" :class "opacity-60")
+      (ui/button "Start syncing"
+                 :disabled loading?
+                 :on-click (fn []
+                             (set-loading? true)
+                             (let [result (:user/info @state/state)
+                                   ex-time (:ExpireTime result)]
+                               (if (and (number? ex-time)
+                                        (< (* ex-time 1000) (js/Date.now)))
+                                 (do
+                                   (vreset! *beta-unavailable? true)
+                                   (maybe-onboarding-show :unavailable))
+
+                                 ;; Logseq sync available
+                                 (maybe-onboarding-show :sync-initiate))
+                               (close-fn)
+                               (set-loading? false))
+                             ))]]))
+
+(rum/defc onboarding-unavailable-file-sync
+  [close-fn]
+
+  [:div.cp__file-sync-unavailable-logseq-sync
+   [:span.head-bg]
+
+   [:h1.text-2xl.font-bold
+    [:span.pr-2.dark:text-white.text-gray-800 "Logseq Sync"]
+    [:span.opacity-80 "is not yet available for you. 😔 "]]
+
+   [:h2
+    "Thanks for creating an account! To ensure that our file syncing service runs well when we release it"
+    [:br]
+    "to our users, we need a little more time to test it. That’s why we decided to first roll it out only to our "
+    [:br]
+    "charitable OpenCollective sponsors. We can notify you once it becomes available for you."]
+
+   [:div.pt-6.flex.justify-end.space-x-2
+    (ui/button "Close" :on-click close-fn :background "gray" :class "opacity-60")]])
+
+(rum/defc onboarding-congrats-successful-sync
+  [close-fn]
+
+  [:div.cp__file-sync-related-normal-modal
+   [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "checkup-list")]]
+
+   [:h1.text-xl.font-semibold.opacity-90.text-center.py-2
+    [:span.dark:opacity-80 "Congrats on your first successful sync!"]]
+
+   [:h2.text-center.dark:opacity-70.text-sm.opacity-90
+    [:div "By using this graph with Logseq Sync you can now transition seamlessly between your different "]
+    [:div
+     [:span "devices. Go to the "]
+     [:span.dark:text-white "All Graphs "]
+     [:span "pages to manage your remote graph or switch to another local graph "]]
+    [:div "and sync it as well."]]
+
+   [:div.cloud-tip.rounded-md.mt-6.py-4
+    [:div.items-center.opacity-90.flex.justify-center
+     [:span.pr-2 (ui/icon "bell-ringing" {:class "font-semibold"})]
+     [:strong "Logseq Sync is still in Beta and we're working on a Pro plan!"]]
+
+    ;; [:ul.flex.py-6.px-4
+    ;;  [:li.it
+    ;;   [:h1.dark:text-white "10"]
+    ;;   [:h2 "Remote Graphs"]]
+    ;;  [:li.it
+    ;;   [:h1.dark:text-white "5G"]
+    ;;   [:h2 "Storage per Graph"]]
+
+    ;;  [:li.it
+    ;;   [:h1.dark:text-white "50G"]
+    ;;   [:h2 "Total Storage"]]]
+    ]
+
+   [:div.pt-6.flex.justify-end.space-x-2
+    (ui/button "Done" :on-click close-fn)]])
+
+(defn open-icloud-graph-clone-picker
+  ([] (open-icloud-graph-clone-picker (state/get-current-repo)))
+  ([repo]
+   (when (and repo (mobile-util/iCloud-container-path? repo))
+     (state/set-modal!
+      (fn [close-fn]
+        (clone-local-icloud-graph-panel repo (util/node-path.basename repo) close-fn))
+      {:close-btn? false :center? true}))))
+
+(defn make-onboarding-panel
+  [type]
+
+  (fn [close-fn]
+
+    (case type
+      :welcome
+      (onboarding-welcome-logseq-sync close-fn)
+
+      :unavailable
+      (onboarding-unavailable-file-sync close-fn)
+
+      :congrats
+      (onboarding-congrats-successful-sync close-fn)
+
+      [:p
+       [:h1.text-xl.font-bold "Not handled!"]
+       [:a.button {:on-click close-fn} "Got it!"]])))
+
+(defn maybe-onboarding-show
+  [type]
+  (when-not (get (state/sub :file-sync/onboarding-state) (keyword type))
+    (try
+      (let [current-repo (state/get-current-repo)
+            local-repo?  (= current-repo config/local-repo)
+            login?       (boolean (state/sub :auth/id-token))]
+
+        (when login?
+          (case type
+
+            :welcome
+            (when (or local-repo?
+                      (:GraphUUID (repo-handler/get-detail-graph-info current-repo)))
+              (throw (js/Error. "current repo have been local or remote graph")))
+
+            (:sync-initiate :sync-learn :sync-history)
+            (do (quick-tour/ready
+                 (fn []
+                   (quick-tour/start-file-sync type)
+                   (state/set-state! [:file-sync/onboarding-state type] true)))
+                (throw (js/Error. nil)))
+            :default)
+
+          (state/pub-event! [:file-sync/onboarding-tip type])
+          (state/set-state! [:file-sync/onboarding-state (keyword type)] true)))
+      (catch js/Error e
+        (js/console.warn "[onboarding SKIP] " (name type) e)))))

+ 477 - 0
src/main/frontend/components/file_sync.css

@@ -0,0 +1,477 @@
+.cp__file-sync {
+  &-indicator {
+    --ls-color-file-sync-error: #ff0000;
+    --ls-color-file-sync-pending: #ffbb4d;
+    --ls-color-file-sync-idle: #04b404;
+
+    a.cloud {
+      position: relative;
+      opacity: 1 !important;
+      cursor: pointer;
+
+      &:active {
+        opacity: .5 !important;
+      }
+
+      .ti {
+        opacity: .5;
+      }
+
+      &:hover .ti {
+        opacity: .9;
+      }
+
+      &.on {
+        &:after {
+          content: " ";
+          position: absolute;
+          bottom: 10px;
+          right: 8px;
+          width: 7px;
+          height: 7px;
+          background-color: var(--ls-color-file-sync-pending);
+          border-radius: 100%;
+          opacity: 1;
+        }
+
+        &.syncing {
+          &:after {
+            background-color: var(--ls-color-file-sync-pending);
+          }
+        }
+
+        &.queuing {
+          &:after {
+            background-color: var(--ls-color-file-sync-pending);
+          }
+        }
+
+        &.idle {
+          &:after {
+            background-color: var(--ls-color-file-sync-idle);
+          }
+        }
+      }
+    }
+
+    .debug-status {
+      position: absolute;
+      top: 0;
+      right: 0;
+      background: #bd0000;
+      color: white;
+      font-size: 14px;
+      padding: 5px 8px;
+    }
+
+    .dropdown-wrapper {
+      width: 90vw;
+      position: fixed;
+      right: 5vw;
+      border-radius: 4px;
+
+      .head-ctls {
+        position: absolute;
+        top: 2px;
+        right: 5px;
+      }
+
+      @screen md {
+        width: 400px;
+        position: absolute;
+        right: -10px;
+      }
+
+      > .py-1 {
+        padding: 0;
+      }
+
+      .menu-link {
+        white-space: normal;
+        word-break: break-all;
+        font-size: 13px;
+        padding: 6px 20px;
+
+        > div.flex > div {
+          margin: 0 !important;
+          flex: 1;
+        }
+
+        &.is-first-placeholder {
+          padding: 0 !important;
+          user-select: none;
+          pointer-events: none;
+
+          &:hover {
+            background-color: unset !important;
+          }
+        }
+      }
+
+      i.ti {
+        transform: translate(0);
+        margin-right: 5px;
+      }
+
+      .files-history.is-first, .file-item.is-first {
+        margin-top: 30px;
+        position: relative;
+
+        &:before {
+          margin-top: -30px;
+          margin-left: -15px;
+          margin-right: -15px;
+          content: "Recently Synced";
+          display: flex;
+          font-weight: 600;
+          font-size: 11px;
+          text-transform: uppercase;
+          opacity: .4;
+          padding: 2px 10px 6px;
+        }
+      }
+
+      .file-item.is-first {
+        margin-top: 5px;
+        padding: 0 6px;
+
+        &:before {
+          margin: 0;
+          content: "Upcoming Sync";
+        }
+      }
+    }
+
+    .menu-links-wrapper {
+      padding: 4px 0;
+      max-height: 60vh !important;
+      overflow-y: auto;
+    }
+  }
+
+  &-page-histories {
+    @apply flex;
+
+    width: unset;
+    height: 85vh;
+    margin: 20px -32px -32px -32px;
+    overflow: hidden;
+    border-top: 1px solid var(--ls-tertiary-border-color);
+
+    @screen md {
+      width: 90vw;
+    }
+
+    > h1 {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      width: 90%;
+    }
+
+    &-left {
+      @apply flex h-full;
+
+      visibility: hidden;
+      flex-basis: 100%;
+
+      .version-list {
+        @apply list-none m-0 h-full;
+
+        overflow-y: auto;
+        flex-basis: 30%;
+        border-right: 1px solid var(--ls-tertiary-border-color);
+
+        &-item {
+          .item-link {
+            @apply py-2 px-3;
+
+            border-bottom: 1px solid var(--ls-tertiary-border-color);
+            transition: background-color .3s;
+
+            &:hover, &.active {
+              background-color: var(--ls-tertiary-background-color);
+            }
+
+            &:active {
+              opacity: .8;
+            }
+
+            > strong {
+              display: block;
+              white-space: nowrap;
+              overflow: hidden;
+              text-overflow: ellipsis;
+            }
+          }
+        }
+      }
+
+      > article {
+        @apply flex-1 overflow-hidden h-full;
+
+        /* fix inner content overflow */
+        width: 0;
+
+        .raw-content-editor, .extensions__code {
+          height: 99.9%;
+        }
+
+        .extensions__code-lang {
+          display: none;
+        }
+
+        .code-editor {
+          margin-top: 0;
+        }
+      }
+
+      @screen md {
+        flex-basis: 60%;
+      }
+    }
+
+    &-right {
+      @apply flex-1 h-full hidden pt-4;
+
+      visibility: hidden;
+      border-left: 1px solid var(--ls-secondary-border-color);
+      padding-left: 30px;
+      padding-bottom: 50px;
+      overflow-y: auto;
+
+      @screen md {
+        display: block;
+      }
+    }
+
+    &.is-list-ready {
+      .cp__file-sync-page-histories {
+        &-left, &-right {
+          visibility: visible;
+        }
+      }
+
+      .ready-loading {
+        display: none;
+      }
+    }
+  }
+
+  &-welcome-logseq-sync, &-unavailable-logseq-sync {
+    > .head-bg {
+      @apply flex m-auto mb-10 w-auto sm:w-[500px];
+
+      background-image: url("../img/file-sync-welcome-backer-dark.png");
+      background-size: contain;
+      background-repeat: no-repeat;
+      background-position: center;
+      padding-top: 86px;
+      max-width: 90vw;
+
+      > strong {
+        @apply block bg-gray-200 rounded text-gray-700 text-[10px] font-semibold px-2 py-0.5 opacity-40
+        m-auto translate-y-8;
+      }
+    }
+
+    > h1, > h2 {
+      @apply flex justify-center text-center;
+    }
+
+    > h2 {
+      @apply text-sm opacity-80 pt-3;
+    }
+  }
+
+  &-unavailable-logseq-sync {
+    > .head-bg {
+      background-image: url("../img/file-sync-unavailale-nonbacker-dark.png");
+    }
+  }
+
+  &-related-normal-modal {
+    .icon-wrap {
+      @apply rounded flex justify-center items-center;
+
+      width: 48px;
+      height: 48px;
+      background-color: var(--ls-quaternary-background-color);
+
+      > .ti {
+        font-size: 34px;
+        opacity: .9;
+      }
+    }
+
+    > .folder-tip {
+      @apply mt-5 py-4 rounded-md;
+
+      -webkit-font-smoothing: antialiased;
+      background-color: var(--ls-quaternary-background-color);
+
+      .ti {
+        font-weight: 400;
+        font-size: 20px;
+      }
+
+      h3, h3 > span {
+        @apply dark:text-white flex items-center space-x-2 font-semibold opacity-95;
+      }
+
+      h4 {
+        @apply text-sm opacity-50 pt-1;
+      }
+
+      .out-icloud {
+        @apply w-full;
+
+        button {
+          @apply w-full border-none rounded-t-none translate-y-4;
+        }
+      }
+    }
+
+    > .input-hints {
+      @apply flex-nowrap md:flex-wrap;
+      background-color: var(--ls-primary-background-color);
+
+      .strength-item {
+        @apply flex items-center leading-none opacity-60 mr-2;
+
+        .ti {
+          @apply scale-75;
+        }
+
+        &.included {
+          @apply text-green-700 font-bold;
+
+          .ti {
+            @apply scale-90 font-bold;
+          }
+        }
+      }
+    }
+
+    > .input-pw-strength {
+      > .indicator {
+        @apply space-x-1.5;
+
+        > i {
+          @apply flex-1 bg-gray-200 overflow-hidden cursor-help opacity-90;
+
+          font-size: 0;
+          height: 5px;
+          border-radius: 2px;
+
+          &.active:first-child {
+            @apply bg-red-500;
+          }
+
+          &.active:nth-child(2) {
+            @apply bg-yellow-300;
+          }
+
+          &.active:nth-child(3) {
+            @apply bg-blue-400;
+          }
+
+          &.active:last-child {
+            @apply bg-green-500;
+          }
+        }
+      }
+    }
+
+    > .cloud-tip {
+      background-color: var(--ls-tertiary-background-color);
+
+      > ul {
+        > li.it {
+          @apply flex flex-1 border-l-2 justify-center items-center flex-col py-4;
+
+          border-color: var(--ls-quaternary-background-color);
+
+          &:first-child {
+            @apply border-l-0;
+          }
+
+          h1 {
+            @apply text-5xl relative;
+
+            sup {
+              @apply absolute text-lg font-semibold right-0 translate-x-8 opacity-60;
+            }
+          }
+
+          h2 {
+            @apply text-xs opacity-80 pt-2;
+          }
+        }
+      }
+    }
+
+    .init-remote-pw-tips {
+      > div {
+        background-color: var(--ls-tertiary-background-color);
+        padding: 6px 10px;
+        font-size: 14px;
+        border-radius: 4px;
+
+        p > span {
+          opacity: .6;
+        }
+      }
+    }
+  }
+}
+
+div[label=modal-page-histories] {
+  .panel-content {
+    overflow: hidden;
+  }
+}
+
+html[data-theme='light'] {
+  .cp__file-sync {
+    &-welcome-logseq-sync {
+      > .head-bg {
+        background-image: url("../img/file-sync-welcome-backer-light.png");
+      }
+    }
+
+    &-unavailable-logseq-sync {
+      > .head-bg {
+        background-image: url("../img/file-sync-unavailale-nonbacker-light.png");
+      }
+    }
+
+    &-related-normal-modal {
+      .icon-wrap {
+        background-color: #908E8B;
+
+        .ti {
+          color: whitesmoke;
+        }
+      }
+
+      > .folder-tip {
+        background-color: var(--ls-tertiary-background-color);
+      }
+
+      > .input-hints {
+        background-color: #e1e1e1;
+      }
+    }
+  }
+}
+
+html:not(.is-electron) {
+  .cp__file-sync {
+    &-indicator {
+      a.cloud .ti {
+        opacity: .9;
+      }
+    }
+  }
+}

+ 32 - 105
src/main/frontend/components/header.cljs

@@ -1,6 +1,5 @@
 (ns frontend.components.header
-  (:require ["path" :as path]
-            [cljs-bean.core :as bean]
+  (:require [cljs-bean.core :as bean]
             [frontend.components.export :as export]
             [frontend.components.page-menu :as page-menu]
             [frontend.components.plugins :as plugins]
@@ -8,9 +7,9 @@
             [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.components.file-sync :as fs-sync]
             [frontend.handler.plugin :as plugin-handler]
             [frontend.handler.route :as route-handler]
             [frontend.handler.user :as user-handler]
@@ -20,8 +19,7 @@
             [frontend.ui :as ui]
             [frontend.util :as util]
             [reitit.frontend.easy :as rfe]
-            [rum.core :as rum]
-            [cljs.core.async :as a]))
+            [rum.core :as rum]))
 
 (rum/defc home-button []
   (ui/with-shortcut :go/home "left"
@@ -35,98 +33,15 @@
 
 (rum/defc login < rum/reactive
   []
-  (let [_ (state/sub :auth/id-token)]
-    (when-not config/publishing?
-      (if (user-handler/logged-in?)
-        (ui/dropdown-with-links
-         (fn [{:keys [toggle-fn]}]
-           [:button.button
-            {:on-click toggle-fn}
-            [:span.text-sm.font-medium (user-handler/email)]])
-         [{:title (t :logout)
-           :options {:on-click user-handler/logout}}]
-         {})
-        [:button.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)
-      (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?
-             [:button.button.icon.inline
-              {:on-click toggle-fn}
-              (ui/icon "cloud-off" {:style {:fontSize ui/icon-size}})]
-             [:button.button.icon.inline
-              {: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:"])))))))
-
-
+        loading? (state/sub [:ui/loading? :login])]
+    (when-not (or config/publishing?
+                  (user-handler/logged-in?)
+                  (not (state/enable-sync?)))
+      [:a.button.text-sm.font-medium.block {:on-click #(js/window.open config/LOGIN-URL)}
+       [:span (t :login)]
+       (when loading?
+         [:span.ml-2 (ui/loading "")])])))
 
 (rum/defc left-menu-button < rum/reactive
   [{:keys [on-click]}]
@@ -143,9 +58,9 @@
                            (concat page-menu [{:hr true}]))]
     (ui/dropdown-with-links
      (fn [{:keys [toggle-fn]}]
-       [:button.button.icon
-        {:title "More"
-         :on-click toggle-fn}
+       [:button.button.icon.toolbar-dots-btn
+        {:on-click toggle-fn
+         :title "More"}
         (ui/icon "dots" {:style {:fontSize ui/icon-size}})])
      (->>
       [(when (state/enable-editing?)
@@ -178,7 +93,12 @@
         :options {:href "https://discuss.logseq.com"
                   :title (t :discourse-title)
                   :target "_blank"}
-        :icon (ui/icon "message-circle")}]
+        :icon (ui/icon "brand-discord")}
+
+       (when (and (state/sub :auth/id-token) (user-handler/logged-in?))
+         {:title (str (t :logout) " (" (user-handler/email) ")")
+          :options {:on-click #(user-handler/logout)}
+          :icon  (ui/icon "logout")})]
       (concat page-menu-and-hr)
       (remove nil?))
      {})))
@@ -267,16 +187,23 @@
              (ui/icon "chevron-left" {:style {:fontSize 25}})])))]
 
      [:div.r.flex
-      (when-not file-sync-handler/hiding-login&file-sync
-        (file-sync))
+      (when (and (not file-sync-handler/hiding-login&file-sync)
+                 current-repo
+                 (not (config/demo-graph? current-repo))
+                 (user-handler/alpha-user?))
+        (fs-sync/indicator))
+
+      (when (and (not= (state/get-current-route) :home)
+                 (not custom-home-page?))
+        (home-button))
+
       (when-not file-sync-handler/hiding-login&file-sync
         (login))
+
       (when plugin-handler/lsp-enabled?
         (plugins/hook-ui-items :toolbar))
 
-      (when (and (not= (state/get-current-route) :home)
-                 (not custom-home-page?))
-        (home-button))
+
 
       (when (util/electron?)
         (back-and-forward))

+ 2 - 6
src/main/frontend/components/header.css

@@ -210,7 +210,7 @@
     margin: 0 6px;
 }
 
-.is-mac.is-electron :is(.cp__header, .cp__right-sidebar-topbar) :is(button, .button, a) {
+.is-mac.is-electron :is(.cp__header, .cp__right-sidebar-topbar) :is(button, .button, a):not(.file-sync-item) {
   cursor: default !important;
 }
 
@@ -317,12 +317,8 @@ html.is-native-iphone {
   --ls-headbar-inner-top-padding: 36px;
 
   .left-sidebar-inner {
-    > .wrap {
-      padding-top: 12px;
-    }
-
     .new-page {
-      padding-bottom: 12px;
+      padding-bottom: 32px;
     }
   }
 

+ 4 - 0
src/main/frontend/components/onboarding/index.css

@@ -428,6 +428,10 @@ body[data-page=import] {
         &.back {
           background-color: rgba(0, 0, 0, .3);
         }
+
+        &.bg-gray {
+          @apply bg-gray-400;
+        }
       }
     }
   }

+ 99 - 17
src/main/frontend/components/onboarding/quick_tour.cljs

@@ -13,8 +13,8 @@
 (defn js-load$
   [url]
   (p/create
-    (fn [resolve]
-      (load url resolve))))
+   (fn [resolve]
+     (load url resolve))))
 
 (def JS_ROOT
   (if (= js/location.protocol "file:")
@@ -39,16 +39,16 @@
   (p/let [action (if (string? fn-or-selector)
                    #(d/sel1 fn-or-selector)
                    fn-or-selector)
-          _      (action)
-          _      (p/delay time)]))
+          _ (action)
+          _ (p/delay time)]))
 
 (defn- inject-steps-indicator
   [current total]
 
   (h/render-html
-    [:div.steps
-     [:strong (str "STEP " current)]
-     [:ul (for [i (range total)] [:li {:class (when (= current (inc i)) "active")} i])]]))
+   [:div.steps
+    [:strong (str "STEP " current)]
+    [:ul (for [i (range total)] [:li {:class (when (= current (inc i)) "active")} i])]]))
 
 (defn- create-steps! [^js jsTour]
   [
@@ -77,7 +77,7 @@
 
     :attachTo          {:element ".page.is-journals .page-title" :on "top-end"}
     :beforeShowPromise #(if-not (= (util/safe-lower-case (state/get-current-page))
-                                  (util/safe-lower-case (date/today)))
+                                   (util/safe-lower-case (date/today)))
                           (wait-target (fn []
                                          (router-handler/redirect-to-page! (date/today))
                                          (util/scroll-to-top)) 200)
@@ -116,13 +116,60 @@
                         {:text "Finish" :action (.-complete jsTour)}]}
    ])
 
+(defn- create-steps-file-sync! [^js jsTour]
+  [
+   ;; initiate graph
+   {:id             "sync-initiate"
+    :text           (h/render-html [:section [:h2 "🚀 Initiate synchronization of your current graph"]
+                                    [:p "Clicking here will start the process of uploading your local files to an encrypted remote graph."]])
+    :attachTo       {:element ".cp__file-sync-indicator" :on "bottom"}
+    :canClickTarget true
+    :buttons        [{:text "Cancel" :classes "bg-gray" :action (fn [] (.hide jsTour))}
+                     {:text "Continue" :action (fn []
+                                                 (some->> (js/document.querySelector ".cp__file-sync-indicator a.button")
+                                                          (.click))
+                                                 (.hide jsTour))}]
+    :popperOptions  {:modifiers [{:name    "preventOverflow"
+                                  :options {:padding 20}}
+                                 {:name    "offset"
+                                  :options {:offset [0, 15]}}]}}
+
+   ;; learn
+   {:id             "sync-learn"
+    :text           (h/render-html [:section [:h2 "💡 Learn about your sync status"]
+                                    [:p "Click here to see the progress of your local graph being synced with the cloud."]])
+    :attachTo       {:element ".cp__file-sync-indicator" :on "bottom"}
+    :canClickTarget true
+    :buttons        [{:text "Got it!" :action (fn []
+                                                (.hide jsTour)
+                                                (js/setTimeout #(state/pub-event! [:file-sync/maybe-onboarding-show :congrats]) 3000))}]
+    :popperOptions  {:modifiers [{:name    "preventOverflow"
+                                  :options {:padding 20}}
+                                 {:name    "offset"
+                                  :options {:offset [0, 15]}}]}}
+
+   ;; history
+   {:id                "sync-history"
+    :text              (h/render-html [:section [:h2 "⏱ Go back in time!"]
+                                       [:p "With file sync you can now go through older versions of this page and revert back to them if you like!"]])
+    :attachTo          {:element ".cp__btn_history_version" :on (if (util/mobile?) "bottom" "left")}
+    :beforeShowPromise #(when-let [^js target (js/document.querySelector ".toolbar-dots-btn")]
+                          (.click target)
+                          (p/delay 300))
+    :canClickTarget    true
+    :buttons           [{:text "Got it!" :action (.-hide jsTour)}]
+    :popperOptions     {:modifiers [{:name    "preventOverflow"
+                                     :options {:padding 20}}
+                                    {:name    "offset"
+                                     :options {:offset [0, 15]}}]}}])
+
 (defn start
   []
   (let [^js jsTour (js/Shepherd.Tour.
-                     (bean/->js
-                       {:useModalOverlay    true
-                        :defaultStepOptions {:classes  "cp__onboarding-quick-tour"
-                                             :scrollTo false}}))
+                    (bean/->js
+                     {:useModalOverlay    true
+                      :defaultStepOptions {:classes  "cp__onboarding-quick-tour"
+                                           :scrollTo false}}))
         steps      (create-steps! jsTour)
         steps      (map-indexed #(assoc %2 :text (str (:text %2) (inject-steps-indicator (inc %1) (count steps)))) steps)
         [show-skip! hide-skip!] (make-skip-fns jsTour)]
@@ -139,12 +186,47 @@
 
     (.start jsTour)))
 
-(defn- ready
+(defn start-file-sync
+  [type]
+  (let [^js jsTour (state/sub :file-sync/jstour-inst)
+        ^js jsTour (or jsTour
+                       (let [^js inst (js/Shepherd.Tour.
+                                       (bean/->js
+                                        {:useModalOverlay    true
+                                         :defaultStepOptions {:classes  "cp__onboarding-quick-tour ignore-outside-event"
+                                                              :scrollTo false}}))
+                             steps    (create-steps-file-sync! inst)]
+
+                         (.on inst "show"
+                              (fn []
+                                (js/setTimeout
+                                 #(let [step (.-currentStep inst)]
+                                    (when-let [^js overlay (and step (.contains (.-classList (.-el step)) "ignore-outside-event")
+                                                                (js/document.querySelector ".shepherd-modal-overlay-container"))]
+                                      (.add (.-classList overlay) "ignore-outside-event")
+                                      (some-> (.-target step)
+                                              (.addEventListener "click" (fn [] (.hide inst))))))
+                                 1000)))
+
+                         (doseq [step steps]
+                           (.addStep inst (bean/->js step)))
+
+                         (state/set-state! :file-sync/jstour-inst inst)
+
+                         inst))]
+
+    (js/setTimeout
+     #(.show jsTour (name type)) 200)
+
+    ;(.start jsTour)
+    ))
+
+(defn ready
   [callback]
   (p/then
-    (if (nil? js/window.Shepherd)
-      (load-base-assets$) (p/resolved true))
-    callback))
+   (if (nil? js/window.Shepherd)
+     (load-base-assets$) (p/resolved true))
+   callback))
 
 (def should-guide? false)
 
@@ -155,4 +237,4 @@
 
   ;; TODO: fix logic
   (when should-guide?
-    (ready start)))
+    (ready start)))

+ 7 - 2
src/main/frontend/components/page.cljs

@@ -309,6 +309,7 @@
 (rum/defcs ^:large-vars/cleanup-todo page < rum/reactive
   (rum/local false ::all-collapsed?)
   (rum/local false ::control-show?)
+  (rum/local nil   ::current-page)
   [state {:keys [repo page-name] :as option}]
   (when-let [path-page-name (or page-name
                                 (get-page-name state)
@@ -343,7 +344,8 @@
                   journal?
                   (= page-name (util/page-name-sanity-lc (date/journal-name))))
           *control-show? (::control-show? state)
-          *all-collapsed? (::all-collapsed? state)]
+          *all-collapsed? (::all-collapsed? state)
+          *current-block-page (::current-page state)]
       [:div.flex-1.page.relative
        (merge (if (seq (:block/tags page))
                 (let [page-names (model/get-page-names-by-ids (map :db/id (:block/tags page)))]
@@ -381,7 +383,10 @@
          ;; blocks
          (let [page (if block?
                       (db/entity repo [:block/uuid block-id])
-                      page)]
+                      page)
+               _ (and block? page (reset! *current-block-page (:block/name (:block/page page))))
+               _ (when (and block? (not page))
+                   (route-handler/redirect-to-page! @*current-block-page))]
            (page-blocks-cp repo page {:sidebar? sidebar?}))]]
 
        (when today?

+ 31 - 22
src/main/frontend/components/page_menu.cljs

@@ -72,7 +72,10 @@
                                 page-name)
           developer-mode? (state/sub [:ui/developer-mode?])
           file-path (when (util/electron?) (page-handler/get-page-file-path))
-          _ (state/sub :auth/id-token)]
+          _ (state/sub :auth/id-token)
+          file-sync-graph-uuid (and (user-handler/logged-in?)
+                                    (not file-sync-handler/hiding-login&file-sync)
+                                    (file-sync-handler/get-current-graph-uuid))]
       (when (and page (not block?))
         (->>
          [{:title   (if favorited?
@@ -84,6 +87,31 @@
                          (page-handler/unfavorite-page! page-original-name)
                          (page-handler/favorite-page! page-original-name)))}}
 
+          (when (or (util/electron?) file-sync-graph-uuid)
+            {:title   (t :page/version-history)
+             :options {:on-click
+                       (fn []
+                         (cond
+                           file-sync-graph-uuid
+                           (state/pub-event! [:graph/pick-page-histories file-sync-graph-uuid page-name])
+
+                           (util/electron?)
+                           (shell/get-file-latest-git-log page 100)
+
+                           :else
+                           nil))
+                       :class "cp__btn_history_version"}})
+
+          (when (or (util/electron?)
+                    (mobile-util/native-platform?))
+            {:title   (t :page/copy-page-url)
+             :options {:on-click #(util/copy-to-clipboard!
+                                   (url-util/get-logseq-graph-page-url nil repo page-original-name))}})
+
+          (when-not contents?
+            {:title   (t :page/delete)
+             :options {:on-click #(state/set-modal! (delete-page-dialog page-name))}})
+
           (when-not (mobile-util/native-platform?)
             {:title (t :page/presentation-mode)
              :options {:on-click (fn []
@@ -102,16 +130,6 @@
              {:title   (t :page/open-with-default-app)
               :options {:on-click #(js/window.apis.openPath file-path)}}])
 
-          (when (or (util/electron?)
-                    (mobile-util/native-platform?))
-            {:title   (t :page/copy-page-url)
-             :options {:on-click #(util/copy-to-clipboard!
-                                   (url-util/get-logseq-graph-page-url nil repo page-original-name))}})
-
-          (when-not contents?
-            {:title   (t :page/delete)
-             :options {:on-click #(state/set-modal! (delete-page-dialog page-name))}})
-
           (when (state/get-current-page)
             {:title   (t :export-page)
              :options {:on-click #(state/set-modal!
@@ -127,17 +145,8 @@
                           (if public? false true))
                          (state/close-modal!))}})
 
-          (when (util/electron?)
-            {:title   (t :page/version-history)
-             :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)
+          (when (and (util/electron?) file-path
+                     (not (file-sync-handler/synced-file-graph? repo)))
             {:title   (t :page/open-backup-directory)
              :options {:on-click
                        (fn []

+ 153 - 83
src/main/frontend/components/repo.cljs

@@ -11,7 +11,6 @@
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
-            [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
             [frontend.mobile.util :as mobile-util]
             [frontend.util.text :as text-util]
@@ -19,7 +18,10 @@
             [electron.ipc :as ipc]
             [goog.object :as gobj]
             [frontend.components.encryption :as encryption]
-            [frontend.encrypt :as e]))
+            [frontend.encrypt :as e]
+            [cljs.core.async :as async :refer [go <!]]
+            [frontend.handler.file-sync :as file-sync]
+            [reitit.frontend.easy :as rfe]))
 
 (rum/defc add-repo
   [args]
@@ -30,53 +32,118 @@
         (widgets/add-graph :graph-types graph-types-s)))
     (widgets/add-graph)))
 
+(rum/defc normalized-graph-label
+  [{:keys [url remote? GraphName GraphUUID] :as graph} on-click]
+  (when graph
+    (let [local? (config/local-db? url)]
+      [:span.flex.items-center
+       (if local?
+         (let [local-dir (config/get-local-dir url)
+               graph-name (text-util/get-graph-name-from-path local-dir)]
+           [:a {:title    local-dir
+                :on-click #(on-click graph)}
+            [:span graph-name (and GraphName [:strong.px-1 "(" GraphName ")"])]
+            (when remote? [:strong.pr-1 (ui/icon "cloud")])])
+
+         [:a {:title  GraphUUID
+              :on-click #(on-click graph)}
+          (db/get-repo-path (or url GraphName))
+          (when remote? [:strong.pl-1 (ui/icon "cloud")])])])))
+
+(rum/defc repos-inner
+  [repos]
+  (for [{:keys [url remote? GraphUUID GraphName] :as repo} repos
+        :let [only-cloud? (and remote? (nil? url))]]
+    [:div.flex.justify-between.mb-4.items-center {:key (or url GraphUUID)}
+     (normalized-graph-label repo #(if only-cloud?
+                                     (state/pub-event! [:graph/pick-dest-to-sync repo])
+                                     (state/pub-event! [:graph/switch url])))
+
+     [:div.controls
+      (when (e/encrypted-db? url)
+        [:a.control {:title    "Show encryption information about this graph"
+                     :on-click (fn []
+                                 (if remote?
+                                   (state/pub-event! [:modal/remote-encryption-input-pw-dialog url repo])
+                                   (state/set-modal! (encryption/encryption-dialog url))))}
+         "🔐"])
+
+      (let [loading? (state/sub [:ui/loading? :remove/remote-graph GraphUUID])]
+        [:div.flex.flex-row.items-center
+         (when loading? [:div.ml-2 (ui/loading "")])
+         (ui/tippy {:html [:div.text-sm.max-w-xs
+                           (if only-cloud?
+                             "Deletes this remote graph. Note this can't be recovered."
+                             "Removes Logseq's access to the local file path of your graph. It won't remove your local files.")]
+                    :class "tippy-hover"
+                    :interactive true}
+                   [:a.text-gray-400.ml-4.font-medium.text-sm.whitespace-nowrap
+                    {:on-click (fn []
+                                 (if only-cloud?
+                                   (let [confirm-fn
+                                         (fn []
+                                           (ui/make-confirm-modal
+                                            {:title      [:div
+                                                          {:style {:max-width 700}}
+                                                          (str "Are you sure to permanently delete the graph \"" GraphName "\" from our server?")]
+                                             :sub-title   [:div.small.mt-1
+                                                           "Notice that we can't recover this graph after being deleted. Make sure you have backups before deleting it."]
+                                             :on-confirm (fn [_ {:keys [close-fn]}]
+                                                           (close-fn)
+                                                           (state/set-state! [:ui/loading? :remove/remote-graph GraphUUID] true)
+                                                           (go (<! (file-sync/<delete-graph GraphUUID))
+                                                             (file-sync/load-session-graphs)
+                                                             (state/set-state! [:ui/loading? :remove/remote-graph GraphUUID] false)))}))]
+                                     (state/set-modal! (confirm-fn)))
+                                   (do
+                                     (repo-handler/remove-repo! repo)
+                                     (file-sync/load-session-graphs))))}
+                    (if only-cloud? "Remove" "Unlink")])])]]))
+
 (rum/defc repos < rum/reactive
   []
-  (let [repos (->> (state/sub [:me :repos])
-                   (remove #(= (:url %) config/local-repo)))
-        repos (util/distinct-by :url repos)]
+  (let [login? (boolean (state/sub :auth/id-token))
+        repos (state/sub [:me :repos])
+        repos (util/distinct-by :url repos)
+        remotes (state/sub [:file-sync/remote-graphs :graphs])
+        remotes-loading? (state/sub [:file-sync/remote-graphs :loading])
+        repos (if (and login? (seq remotes))
+                (repo-handler/combine-local-&-remote-graphs repos remotes) repos)
+        repos (remove #(= (:url %) config/local-repo) repos)
+        {remote-graphs true local-graphs false} (group-by (comp boolean :remote?) repos)]
     (if (seq repos)
       [:div#graphs
-       [:h1.title (t :all-graphs)]
-       [:p.ml-2.opacity-70
-        "A \"graph\" in Logseq means a local directory."]
+       [:h1.title (t :graph/all-graphs)]
 
        [:div.pl-1.content.mt-3
-        [:div.flex.flex-row.my-4
-         (when (or (nfs-handler/supported?)
-                   (mobile-util/native-platform?))
-           [:div.mr-8
-            (ui/button
-              (t :open-a-directory)
-              :on-click #(page-handler/ls-dir-files! shortcut/refresh!))])]
-        (for [{:keys [url] :as repo} repos]
-          (let [local? (config/local-db? url)]
-            [:div.flex.justify-between.mb-4 {:key (str "id-" url)}
-             (if local?
-               (let [local-dir (config/get-local-dir url)
-                     graph-name (text-util/get-graph-name-from-path local-dir)]
-                 [:a {:title local-dir
-                      :on-click #(state/pub-event! [:graph/switch url])}
-                  graph-name])
-               [:a {:target "_blank"
-                    :href url}
-                (db/get-repo-path url)])
-             [:div.controls
-              (when (e/encrypted-db? url)
-                [:a.control {:title "Show encryption information about this graph"
-                             :on-click (fn []
-                                         (state/set-modal! (encryption/encryption-dialog url)))}
-                 "🔐"])
-              [:a.text-gray-400.ml-4.font-medium.text-sm
-               {:title "No worries, unlink this graph will clear its cache only, it does not remove your files on the disk."
-                :on-click (fn []
-                            (repo-handler/remove-repo! repo))}
-               (t :unlink)]]]))]]
-      (widgets/add-graph))))
 
-(defn refresh-cb []
-  (page-handler/create-today-journal!)
-  (shortcut/refresh!))
+        [:div
+         [:h2.text-lg.font-medium.my-4 (str (t :graph/local-graphs) ":")]
+         (when (seq local-graphs)
+           (repos-inner local-graphs))
+
+         [:div.flex.flex-row.my-4
+          (when (or (nfs-handler/supported?)
+                    (mobile-util/native-platform?))
+            [:div.mr-8
+             (ui/button
+               (t :open-a-directory)
+               :on-click #(page-handler/ls-dir-files! shortcut/refresh!))])]]
+
+        (when (seq remote-graphs)
+          [:div
+           [:hr]
+           [:div.flex.align-items.justify-between
+            [:h2.text-lg.font-medium.my-4 (str (t :graph/remote-graphs) ":")]
+            [:div
+             (ui/button
+              [:span.flex.items-center "Refresh"
+               (when remotes-loading? [:small.pl-2 (ui/loading nil)])]
+              :background "gray"
+              :disabled remotes-loading?
+              :on-click #(file-sync/load-session-graphs))]]
+           (repos-inner remote-graphs)])]]
+      (widgets/add-graph))))
 
 (defn- check-multiple-windows?
   [state]
@@ -85,18 +152,25 @@
       (reset! (::electron-multiple-windows? state) multiple-windows?))))
 
 (defn- repos-dropdown-links [repos current-repo *multiple-windows?]
-  (let [switch-repos (remove (fn [repo] (= current-repo (:url repo))) repos) ; exclude current repo
+  (let [switch-repos (if-not (nil? current-repo)
+                       (remove (fn [repo] (= current-repo (:url repo))) repos) repos) ; exclude current repo
         repo-links (mapv
-                    (fn [{:keys [url]}]
-                      (let [repo-path (db/get-repo-name url)
-                            short-repo-name (text-util/get-graph-name-from-path repo-path)]
-                        {:title short-repo-name
-                         :hover-detail repo-path ;; show full path on hover
-                         :options {:class "ml-1"
-                                   :on-click (fn [e]
-                                               (if (gobj/get e "shiftKey")
-                                                 (state/pub-event! [:graph/open-new-window url])
-                                                 (state/pub-event! [:graph/switch url])))}}))
+                    (fn [{:keys [url remote? GraphName GraphUUID] :as graph}]
+                      (let [local? (config/local-db? url)
+                            repo-path (if local? (db/get-repo-name url) GraphName )
+                            short-repo-name (if local? (text-util/get-graph-name-from-path repo-path) GraphName)]
+                        (when short-repo-name
+                          {:title        [:span.flex.items-center.whitespace-nowrap short-repo-name
+                                          (when remote? [:span.pl-1
+                                                         {:title (str "<" GraphName "> #" GraphUUID)}
+                                                         (ui/icon "cloud")])]
+                           :hover-detail repo-path ;; show full path on hover
+                           :options      {:on-click (fn [e]
+                                                      (if (gobj/get e "shiftKey")
+                                                        (state/pub-event! [:graph/open-new-window url])
+                                                        (if-not local?
+                                                          (state/pub-event! [:graph/pick-dest-to-sync graph])
+                                                          (state/pub-event! [:graph/switch url]))))}})))
                     switch-repos)
         refresh-link (let [nfs-repo? (config/local-db? current-repo)]
                        (when (and nfs-repo?
@@ -105,64 +179,60 @@
                                       (mobile-util/native-platform?)))
                          {:title (t :sync-from-local-files)
                           :hover-detail (t :sync-from-local-files-detail)
-                          :options {:on-click
-                                    (fn []
-                                      (state/pub-event!
-                                       [:modal/show
-                                        [:div {:style {:max-width 700}}
-                                         [:p (t :sync-from-local-changes-detected)]
-                                         (ui/button
-                                          (t :yes)
-                                          :autoFocus "on"
-                                          :large? true
-                                          :on-click (fn []
-                                                      (state/close-modal!)
-                                                      (nfs-handler/refresh! (state/get-current-repo) refresh-cb)))]]))}}))
+                          :options {:on-click #(state/pub-event! [:graph/ask-for-re-fresh])}}))
         reindex-link {:title        (t :re-index)
                       :hover-detail (t :re-index-detail)
                       :options (cond->
                                 {:on-click
                                  (fn []
-                                   (state/pub-event! [:graph/ask-for-re-index *multiple-windows?]))})}
-        new-window-link (when (util/electron?)
-                          {:title        (t :open-new-window)
-                           :options {:on-click #(state/pub-event! [:graph/open-new-window nil])}})]
+                                   (state/pub-event! [:graph/ask-for-re-index *multiple-windows?]))})}]
     (->>
      (concat repo-links
              [(when (seq repo-links) {:hr true})
               {:title (t :new-graph) :options {:on-click #(page-handler/ls-dir-files! shortcut/refresh!)}}
               {:title (t :all-graphs) :options {:href (rfe/href :repos)}}
               refresh-link
-              reindex-link
-              new-window-link])
+              reindex-link])
      (remove nil?))))
 
 (rum/defcs repos-dropdown < rum/reactive
   (rum/local false ::electron-multiple-windows?)
   [state]
-  (let [multiple-windows? (::electron-multiple-windows? state)]
-    (when-let [current-repo (state/sub :git/current-repo)]
+  (let [multiple-windows? (::electron-multiple-windows? state)
+        current-repo (state/sub :git/current-repo)
+        login? (boolean (state/sub :auth/id-token))]
+    (when (or login? current-repo)
       (let [repos (state/sub [:me :repos])
-            repos (remove (fn [r] (= config/local-repo (:url r))) repos)
+            remotes (state/sub [:file-sync/remote-graphs :graphs])
+            repos (if (and (seq remotes) login?)
+                    (repo-handler/combine-local-&-remote-graphs repos remotes) repos)
             links (repos-dropdown-links repos current-repo multiple-windows?)
             render-content (fn [{:keys [toggle-fn]}]
-                             (let [repo-path (db/get-repo-name current-repo)
-                                   short-repo-name (db/get-short-repo-name repo-path)]
+                             (let [valid-remotes-but-locals? (and (seq repos) (not (some :url repos)))
+                                   remote? (when-not valid-remotes-but-locals?
+                                             (:remote? (first (filter #(= current-repo (:url %)) repos))))
+                                   repo-path (if-not valid-remotes-but-locals?
+                                               (db/get-repo-name current-repo) "")
+                                   short-repo-name (if-not valid-remotes-but-locals?
+                                                     (db/get-short-repo-name repo-path) "Select a Graph")]
                                [:a.item.group.flex.items-center.p-2.text-sm.font-medium.rounded-md
+
                                 {:on-click (fn []
                                              (check-multiple-windows? state)
                                              (toggle-fn))
-                                 :title repo-path} ;; show full path on hover
+                                 :title    repo-path}       ;; show full path on hover
                                 (ui/icon "database mr-2" {:style {:font-size 16} :id "database-icon"})
                                 [:div.graphs
                                  [:span#repo-switch.block.pr-2.whitespace-nowrap
-                                  [:span [:span#repo-name.font-medium short-repo-name]]
+                                  [:span [:span#repo-name.font-medium
+                                          (if (= config/local-repo short-repo-name) "Demo" short-repo-name)
+                                          (when remote? [:span.pl-1 (ui/icon "cloud")])]]
                                   [:span.dropdown-caret.ml-2 {:style {:border-top-color "#6b7280"}}]]]]))
             links-header (cond->
-                          {:modal-class (util/hiccup->class
-                                         "origin-top-right.absolute.left-0.mt-2.rounded-md.shadow-lg")}
-                           (> (count repos) 1) ; show switch to if there are multiple repos
-                           (assoc :links-header [:div.font-medium.text-sm.opacity-60.px-4.pt-2
+                           {:modal-class (util/hiccup->class
+                                           "origin-top-right.absolute.left-0.mt-2.rounded-md.shadow-lg")}
+                           (> (count repos) 1)              ; show switch to if there are multiple repos
+                           (assoc :links-header [:div.font-medium.text-sm.opacity-60.px-4.pt-2.pb-1
                                                  "Switch to:"]))]
         (when (seq repos)
           (ui/dropdown-with-links render-content links links-header))))))

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

@@ -14,13 +14,14 @@
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.user :as user-handler]
             [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.file-sync :as file-sync-handler]
             [frontend.modules.instrumentation.core :as instrument]
             [frontend.modules.shortcut.data-helper :as shortcut-helper]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [electron.ipc :as ipc]
             [promesa.core :as p]
-            [frontend.util :refer [classnames] :as util]
+            [frontend.util :refer [classnames web-platform?] :as util]
             [frontend.version :refer [version]]
             [goog.object :as gobj]
             [reitit.frontend.easy :as rfe]
@@ -374,7 +375,7 @@
       :else
       (notification/show! (str "The page \"" value "\" doesn't exist yet. Please create that page first, and then try again.") :warning))))
 
-(defn journal-row [t enable-journals?]
+(defn journal-row [enable-journals?]
   (toggle "enable_journals"
           (t :settings-page/enable-journals)
           enable-journals?
@@ -398,7 +399,7 @@
 ;;             (let [value (not enable-block-timestamps?)]
 ;;               (config-handler/set-config! :feature/enable-block-timestamps? value)))))
 
-(defn encryption-row [t enable-encryption?]
+(defn encryption-row [enable-encryption?]
   (toggle "enable_encryption"
           (t :settings-page/enable-encryption)
           enable-encryption?
@@ -423,15 +424,15 @@
                       (route-handler/redirect! {:to :shortcut-setting}))
      :-for         "customize_shortcuts"}))
 
-(defn zotero-settings-row [_t]
+(defn zotero-settings-row []
   [:div.it.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start
    [:label.block.text-sm.font-medium.leading-5.opacity-70
     {:for "zotero_settings"}
-    "Zotero settings"]
+    "Zotero"]
    [:div.mt-1.sm:mt-0.sm:col-span-2
     [:div
      (ui/button
-       "Zotero settings"
+       "Settings"
        :class "text-sm p-1"
        :style {:margin-top "0px"}
        :on-click
@@ -548,8 +549,6 @@
         preferred-date-format (state/get-date-formatter)
         preferred-workflow (state/get-preferred-workflow)
         enable-timetracking? (state/enable-timetracking?)
-        enable-journals? (state/enable-journals? current-repo)
-        enable-encryption? (state/enable-encryption? current-repo)
         enable-all-pages-public? (state/all-pages-public?)
         logical-outdenting? (state/logical-outdenting?)
         enable-tooltip? (state/enable-tooltip?)
@@ -570,23 +569,7 @@
      (when-not (or (util/mobile?) (mobile-util/native-platform?))
        (tooltip-row t enable-tooltip?))
      (timetracking-row t enable-timetracking?)
-     (journal-row t enable-journals?)
-     (when (not enable-journals?)
-       [:div.it.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start
-        [:label.block.text-sm.font-medium.leading-5.opacity-70
-         {:for "default page"}
-         (t :settings-page/home-default-page)]
-        [:div.mt-1.sm:mt-0.sm:col-span-2
-         [:div.max-w-lg.rounded-md.sm:max-w-xs
-          [:input#home-default-page.form-input.is-small.transition.duration-150.ease-in-out
-           {:default-value (state/sub-default-home-page)
-            :on-blur       update-home-page
-            :on-key-press  (fn [e]
-                             (when (= "Enter" (util/ekey e))
-                               (update-home-page e)))}]]]])
-     (encryption-row t enable-encryption?)
      (enable-all-pages-public-row t enable-all-pages-public?)
-     (zotero-settings-row t)
      (auto-push-row t current-repo enable-git-auto-push?)]))
 
 (rum/defc settings-git
@@ -610,17 +593,14 @@
      [:p (t :settings-page/git-confirm)])])
 
 (rum/defc settings-advanced < rum/reactive
-  [current-repo]
+  []
   (let [instrument-disabled? (state/sub :instrument/disabled?)
         developer-mode? (state/sub [:ui/developer-mode?])
-        https-agent-opts (state/sub [:electron/user-cfgs :settings/agent])
-        enable-flashcards? (state/enable-flashcards? current-repo)]
+        https-agent-opts (state/sub [:electron/user-cfgs :settings/agent])]
     [:div.panel-wrap.is-advanced
      (when (and util/mac? (util/electron?)) (app-auto-update-row t))
      (usage-diagnostics-row t instrument-disabled?)
      (when-not (mobile-util/native-platform?) (developer-mode-row t developer-mode?))
-     (when (and (util/electron?) config/enable-plugins?) (plugin-system-switcher-row))
-     (flashcards-switcher-row enable-flashcards?)
      (when (util/electron?) (https-user-agent-row https-agent-opts))
      (clear-cache-row t)
 
@@ -628,6 +608,53 @@
        :warning
        [:p "Clearing the cache will discard open graphs. You will lose unsaved changes."])]))
 
+(rum/defc sync-enabled-switcher
+  [enabled?]
+  (ui/toggle enabled?
+             (fn []
+               (let [value (not enabled?)]
+                 (storage/set :logseq-sync-enabled value)
+                 (state/set-state! :feature/enable-sync? value)))
+             true))
+
+(defn sync-switcher-row [enabled?]
+  (row-with-button-action
+   {:left-label (str (t :settings-page/sync) " 🔐")
+    :action (sync-enabled-switcher enabled?)}))
+
+(rum/defc settings-features < rum/reactive
+  []
+  (let [current-repo (state/get-current-repo)
+        enable-journals? (state/enable-journals? current-repo)
+        enable-encryption? (state/enable-encryption? current-repo)
+        enable-flashcards? (state/enable-flashcards? current-repo)
+        enable-sync? (state/enable-sync?)]
+    [:div.panel-wrap.is-features.mb-8
+     (journal-row enable-journals?)
+     (when (not enable-journals?)
+       [:div.it.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start
+        [:label.block.text-sm.font-medium.leading-5.opacity-70
+         {:for "default page"}
+         (t :settings-page/home-default-page)]
+        [:div.mt-1.sm:mt-0.sm:col-span-2
+         [:div.max-w-lg.rounded-md.sm:max-w-xs
+          [:input#home-default-page.form-input.is-small.transition.duration-150.ease-in-out
+           {:default-value (state/sub-default-home-page)
+            :on-blur       update-home-page
+            :on-key-press  (fn [e]
+                             (when (= "Enter" (util/ekey e))
+                               (update-home-page e)))}]]]])
+     (when (and (util/electron?) config/enable-plugins?) (plugin-system-switcher-row))
+     (flashcards-switcher-row enable-flashcards?)
+     (zotero-settings-row)
+     (encryption-row enable-encryption?)
+
+     (when-not web-platform?
+       [:div
+        [:hr]
+        [:h2.mb-4 "Alpha test (sponsors only)"]
+        (sync-switcher-row enable-sync?)])]))
+
 (rum/defcs settings
   < (rum/local [:general :general] ::active)
     {:will-mount
@@ -650,16 +677,20 @@
      [:header
       [:h1.title (t :settings)]]
 
-     [:div.cp__settings-inner.md:flex
+     [:div.cp__settings-inner
 
       [:aside.md:w-64 {:style {:min-width "10rem"}}
        [:ul.settings-menu
         (for [[label id text icon]
               [[:general "general" (t :settings-page/tab-general) (ui/icon "adjustments" {:style {:font-size 20}})]
                [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing" {:style {:font-size 20}})]
-               (when-not (mobile-util/native-platform?)
+               (when (and
+                      (util/electron?)
+                      (not (file-sync-handler/synced-file-graph? current-repo)))
                  [:git "git" (t :settings-page/tab-version-control) (ui/icon "history" {:style {:font-size 20}})])
                [:advanced "advanced" (t :settings-page/tab-advanced) (ui/icon "bulb" {:style {:font-size 20}})]
+               [:features "features" (t :settings-page/tab-features) (ui/icon "app-feature" {:style {:font-size 20}
+                                                                                             :extension? true})]
                (when plugins-of-settings
                  [:plugins-setting "plugins" (t :settings-of-plugins) (ui/icon "puzzle")])]]
 
@@ -694,6 +725,9 @@
          (settings-git)
 
          :advanced
-         (settings-advanced current-repo)
+         (settings-advanced)
+
+         :features
+         (settings-features)
 
          nil)]]]))

+ 11 - 9
src/main/frontend/components/settings.css

@@ -14,17 +14,19 @@
   }
 
   &-inner {
+    @apply flex flex-col md:flex-row;
+    
     > aside {
-        border-right: 0px solid var(--ls-quaternary-background-color);
-        border-bottom: 1px solid var(--ls-quaternary-background-color);
-        @screen md {
-            border-right: 1px solid var(--ls-quaternary-background-color);
-            border-bottom: 0px solid var(--ls-quaternary-background-color);
-        }
+      border-right: 0 solid var(--ls-quaternary-background-color);
+      border-bottom: 1px solid var(--ls-quaternary-background-color);
+
+      @screen md {
+        border-right: 1px solid var(--ls-quaternary-background-color);
+        border-bottom: 0 solid var(--ls-quaternary-background-color);
+      }
 
       ul {
-        padding: 12px;
-        padding-left: 5px;
+        padding: 12px 12px 12px 5px;
         margin: 0;
 
         > li {
@@ -68,7 +70,6 @@
     > article {
       flex: 1;
       padding: 0 12px 12px;
-      max-height: 70vh;
       min-height: 380px;
       width: auto;
       overflow: auto;
@@ -76,6 +77,7 @@
       margin-bottom: -17px;
 
       @screen md {
+        max-height: 70vh;
         width: 680px;
       }
     }

+ 20 - 34
src/main/frontend/components/sidebar.cljs

@@ -225,7 +225,14 @@
                    (when (some (fn [sel] (boolean (.closest target sel)))
                                [".favorites .bd" ".recent .bd" ".dropdown-wrapper" ".nav-header"])
                      (close-modal-fn)))}
-     [:div.flex.flex-col.pb-4.wrap.gap-4
+
+     [:div.flex.flex-col.pb-4.wrap.gap-4.relative
+      (when (mobile-util/native-platform?)
+        [:div.fake-bar.absolute
+         [:button
+          {:on-click state/toggle-left-sidebar!}
+          (ui/icon "menu-2" {:style {:fontSize ui/icon-size}})]])
+
       [:nav.px-4.flex.flex-col.gap-1 {:aria-label "Navigation menu"}
        (repo/repos-dropdown)
 
@@ -273,16 +280,15 @@
 
       (when (and left-sidebar-open? (not config/publishing?)) (recent-pages t))
 
-      (when-not (mobile-util/native-platform?)
-        [:footer.px-2 {:class "new-page"}
-         (when-not config/publishing?
-           [:a.item.group.flex.items-center.px-2.py-2.text-sm.font-medium.rounded-md.new-page-link
-            {:on-click (fn []
-                         (and (util/sm-breakpoint?)
-                              (state/toggle-left-sidebar!))
-                         (state/pub-event! [:go/search]))}
-            (ui/icon "circle-plus mr-3" {:style {:font-size 20}})
-            [:span.flex-1 (t :right-side-bar/new-page)]])])]]))
+      [:footer.px-2 {:class "new-page"}
+       (when-not config/publishing?
+         [:a.item.group.flex.items-center.px-2.py-2.text-sm.font-medium.rounded-md.new-page-link
+          {:on-click (fn []
+                       (and (util/sm-breakpoint?)
+                            (state/toggle-left-sidebar!))
+                       (state/pub-event! [:go/search]))}
+          (ui/icon "circle-plus mr-3" {:style {:font-size 20}})
+          [:span.flex-1 (t :right-side-bar/new-page)]])]]]))
 
 (rum/defc left-sidebar < rum/reactive
   [{:keys [left-sidebar-open? route-match]}]
@@ -399,20 +405,6 @@
                        (:current-parsing-file state))]]]]
     (ui/progress-bar-with-label width left-label (str finished "/" total))))
 
-(rum/defc file-sync-download-progress < rum/static
-  [state]
-  (let [finished (or (:finished state) 0)
-        total (:total state)
-        width (js/Math.round (* (.toFixed (/ finished total) 2) 100))
-        left-label [:div.flex.flex-row.font-bold
-                    "Downloading"
-                    [:div.hidden.md:flex.flex-row
-                     [:span.mr-1 ": "]
-                     [:ul
-                      (for [file (:downloading-files state)]
-                        [:li file])]]]]
-    (ui/progress-bar-with-label width left-label (str finished "/" total))))
-
 (rum/defc main-content < rum/reactive db-mixins/query
   {:init (fn [state]
            (when-not @sidebar-inited?
@@ -438,16 +430,8 @@
         loading-files? (when current-repo (state/sub [:repo/loading-files? current-repo]))
         journals-length (state/sub :journals-length)
         latest-journals (db/get-latest-journals (state/get-current-repo) journals-length)
-        graph-parsing-state (state/sub [:graph/parsing-state current-repo])
-        graph-file-sync-download-init-state (state/sub [:file-sync/download-init-progress current-repo])]
+        graph-parsing-state (state/sub [:graph/parsing-state current-repo])]
     (cond
-      (or
-       (:downloading? graph-file-sync-download-init-state)
-       (not= (:total graph-file-sync-download-init-state) (:finished graph-file-sync-download-init-state)))
-      [:div.flex.items-center.justify-center.full-height-without-header
-       [:div.flex-1
-        (file-sync-download-progress graph-file-sync-download-init-state)]]
-
       (or
        (:graph-loading? graph-parsing-state)
        (not= (:total graph-parsing-state) (:finished graph-parsing-state)))
@@ -573,6 +557,7 @@
         settings-open? (state/sub :ui/settings-open?)
         left-sidebar-open?  (state/sub :ui/left-sidebar-open?)
         wide-mode? (state/sub :ui/wide-mode?)
+        onboarding-state (state/sub :file-sync/onboarding-state)
         right-sidebar-blocks (state/sub-right-sidebar-blocks)
         route-name (get-in route-match [:data :name])
         global-graph-pages? (= :graph route-name)
@@ -598,6 +583,7 @@
       :settings-open? settings-open?
       :sidebar-blocks-len (count right-sidebar-blocks)
       :system-theme? system-theme?
+      :onboarding-state onboarding-state
       :preferred-language preferred-language
       :on-click      (fn [e]
                        (editor-handler/unhighlight-blocks!)

+ 22 - 3
src/main/frontend/components/sidebar.css

@@ -76,8 +76,8 @@
   height: 100%;
   padding-top: 12px;
   width: var(--ls-left-sidebar-sm-width);
-  overflow-x: hidden;
   overflow-y: auto;
+  overflow-x: hidden;
   background-color: var(--ls-primary-background-color);
   transition: transform .3s;
   transform: translateX(-100%);
@@ -89,11 +89,27 @@
     height: calc(100vh - var(--ls-headbar-inner-top-padding) - 50px);
     margin-top: 40px;
     padding-bottom: 60px;
-    overflow-y: auto;
+    width: 100%;
+
+    > .fake-bar {
+      @apply w-full px-5 pt-1 sm:hidden;
+
+      top: -45px;
+    }
   }
 
   .dropdown-wrapper {
     min-width: 180px;
+    margin-top: 1px;
+
+    .menu-link {
+      padding: 5px 15px;
+      opacity: .8;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
   }
 
   .page-icon {
@@ -270,7 +286,7 @@
   width: 0;
   top: var(--ls-headbar-inner-top-padding);
   left: 0;
-  z-index: 9;
+  z-index: var(--ls-z-index-level-5);
   transition: width 1.2s;
 
   a {
@@ -296,6 +312,7 @@
 
     .left-sidebar-inner {
       transform: translateX(0);
+      overflow: visible;
     }
 
     > .shade-mask {
@@ -317,6 +334,7 @@
 
   @screen sm {
     width: 0;
+    z-index: var(--ls-z-index-level-1);
 
     &:before {
       background-color: var(--ls-secondary-background-color);
@@ -524,3 +542,4 @@ html[data-theme='dark'] {
 .full-height-without-header {
   height: calc(100vh - var(--ls-headbar-height) - 4rem);
 }
+

+ 11 - 6
src/main/frontend/components/svg.cljs

@@ -23,15 +23,20 @@
            {:d
             "M64.177 100.069a7.889 7.889 0 01-5.6-2.316l-55.98-55.98a7.92 7.92 0 010-11.196c3.086-3.085 8.105-3.092 11.196 0l50.382 50.382 50.382-50.382a7.92 7.92 0 0111.195 0c3.086 3.086 3.092 8.104 0 11.196l-55.98 55.98a7.892 7.892 0 01-5.595 2.316z"}]])
 
-(defonce loading
-         [:svg.h-5.w-5.animate-spin
-          {:version  "1.1"
+
+(defn loader-fn [opts]
+  [:svg.animate-spin
+   (merge {:version  "1.1"
            :view-box "0 0 24 24"
            :fill     "none"
+           :class    "w-5 h-5"
            :display  "inline-block"}
-          [:circle.opacity-25 {:cx 12 :cy 12 :r 10 :stroke "currentColor" :stroke-width 4}]
-          [:path.opacity-75 {:fill "currentColor"
-                             :d    "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]])
+          opts)
+   [:circle.opacity-25 {:cx 12 :cy 12 :r 10 :stroke "currentColor" :stroke-width 4}]
+   [:path.opacity-75 {:fill "currentColor"
+                      :d    "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]])
+
+(defonce loading (loader-fn nil))
 
 (defn- hero-icon
   ([d]

+ 6 - 1
src/main/frontend/components/theme.cljs

@@ -9,11 +9,12 @@
             [frontend.state :as state]
             [frontend.components.settings :as settings]
             [frontend.rum :refer [use-mounted]]
+            [frontend.storage :as storage]
             [rum.core :as rum]))
 
 (rum/defc container
   [{:keys [route theme on-click current-repo nfs-granted? db-restoring?
-           settings-open? sidebar-open? system-theme? sidebar-blocks-len preferred-language]} child]
+           settings-open? sidebar-open? system-theme? sidebar-blocks-len onboarding-state preferred-language]} child]
   (let [mounted-fn (use-mounted)
         [restored-sidebar? set-restored-sidebar?] (rum/use-state false)]
 
@@ -89,6 +90,10 @@
          (fn [] [:div.settings-modal (settings/settings)])))
      [settings-open?])
 
+    (rum/use-effect!
+     #(storage/set :file-sync/onboarding-state onboarding-state)
+     [onboarding-state])
+
     [:div
      {:class    (util/classnames
                  [(str theme "-theme")

+ 34 - 7
src/main/frontend/config.cljs

@@ -19,19 +19,27 @@
 
 (def test? false)
 
+(goog-define ENABLE-FILE-SYNC-PRODUCTION false)
+
 ;; prod env
 ;; (goog-define FILE-SYNC-PROD? true)
 ;; (goog-define LOGIN-URL
-;;              "https://logseq.auth.us-east-1.amazoncognito.com/login?client_id=7ns5v1pu8nrbs04rvdg67u4a7c&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
+;;              "https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
 ;; (goog-define API-DOMAIN "api-prod.logseq.com")
 ;; (goog-define WS-URL "wss://b2rp13onu2.execute-api.us-east-1.amazonaws.com/production?graphuuid=%s")
 
-;; dev env
-(goog-define FILE-SYNC-PROD? false)
-(goog-define LOGIN-URL
-             "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
-(goog-define API-DOMAIN "api.logseq.com")
-(goog-define WS-URL "wss://og96xf1si7.execute-api.us-east-2.amazonaws.com/production?graphuuid=%s")
+(if ENABLE-FILE-SYNC-PRODUCTION
+  (do (def FILE-SYNC-PROD? true)
+      (def LOGIN-URL
+        "https://logseq-prod.auth.us-east-1.amazoncognito.com/login?client_id=3c7np6bjtb4r1k1bi9i049ops5&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
+      (def API-DOMAIN "api.logseq.com")
+      (def WS-URL "wss://ws.logseq.com/file-sync?graphuuid=%s"))
+
+  (do (def FILE-SYNC-PROD? false)
+      (def LOGIN-URL
+        "https://logseq-test2.auth.us-east-2.amazoncognito.com/login?client_id=3ji1a0059hspovjq5fhed3uil8&response_type=code&scope=email+openid+phone&redirect_uri=logseq%3A%2F%2Fauth-callback")
+      (def API-DOMAIN "api-dev.logseq.com")
+      (def WS-URL "wss://ws-dev.logseq.com/file-sync?graphuuid=%s")))
 
 ;; feature flags
 (goog-define ENABLE-FILE-SYNC false)
@@ -298,6 +306,25 @@
          (->> (take-last 2 (string/split repo-url #"/"))
               (string/join "_")))))
 
+(defn get-string-repo-dir
+  [repo-dir]
+  (if (mobile-util/native-ios?)
+    (str (if (mobile-util/iCloud-container-path? repo-dir)
+           "iCloud"
+           (cond (mobile-util/native-iphone?)
+                 "On My iPhone"
+
+                 (mobile-util/native-ipad?)
+                 "On My iPad"
+
+                 :else
+                 "Local"))
+         (->> (string/split repo-dir "Documents/")
+              last
+              js/decodeURIComponent
+              (str "/" (string/capitalize app-name) "/")))
+    (get-repo-dir repo-dir)))
+
 (defn get-repo-path
   [repo-url path]
   (if (and (or (util/electron?) (mobile-util/native-platform?))

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

@@ -65,5 +65,5 @@
   ;; stop is called before any code is reloaded
   ;; this is controlled by :before-load in the config
   (handler/stop!)
-  (sync/sync-stop)
+  (sync/<sync-stop)
   (js/console.log "stop"))

+ 5 - 4
src/main/frontend/db/conn.cljs

@@ -13,10 +13,11 @@
 
 (defn get-repo-path
   [url]
-  (if (util/starts-with? url "http")
-    (->> (take-last 2 (string/split url #"/"))
-         (string/join "/"))
-    url))
+  (when url
+    (if (util/starts-with? url "http")
+      (->> (take-last 2 (string/split url #"/"))
+           (string/join "/"))
+      url)))
 
 (defn get-repo-name
   [repo]

+ 10 - 29
src/main/frontend/dicts.cljc

@@ -92,7 +92,6 @@
         :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/add-to-favorites "Add to Favorites"
@@ -184,9 +183,11 @@
         :settings-page/tab-shortcuts "Shortcuts"
         :settings-page/tab-version-control "Version control"
         :settings-page/tab-advanced "Advanced"
-        :settings-page/plugin-system "Plug-in system"
+        :settings-page/tab-features "Features"
+        :settings-page/plugin-system "Plugins"
         :settings-page/enable-flashcards "Flashcards"
         :settings-page/network-proxy "Network proxy"
+        :settings-page/sync "Sync"
         :logseq "Logseq"
         :on "ON"
         :more-options "More options"
@@ -205,7 +206,6 @@
         :re-index-detail "Rebuild the graph"
         :re-index-multiple-windows-warning "You need to close the other windows before re-index this graph."
         :re-index-discard-unsaved-changes-warning "Re-index will discard the current graph, and then processes all the files again as they are currently stored on disk. You will lose unsaved changes and it might take a while. Continue?"
-        :open-new-window "New window"
         :sync-from-local-files "Refresh"
         :sync-from-local-files-detail "Import changes from local files"
         :sync-from-local-changes-detected "Refresh detects and processes files modified on your disk and diverged from the actual Logseq page content. Continue?"
@@ -225,6 +225,9 @@
         :graph/save "Saving..."
         :graph/save-success "Saved successfully"
         :graph/save-error "Save failed"
+        :graph/all-graphs "All graphs"
+        :graph/local-graphs "Local graphs"
+        :graph/remote-graphs "Remote graphs"
         :cards-view "View cards"
         :publishing "Publishing"
         :export "Export"
@@ -332,7 +335,7 @@
         :select.graph/add-graph "Yes, add another graph"
 
         :file-sync/other-user-graph "Current local graph is bound to other user's remote graph. So can't start syncing."
-        :file-sync/graph-deleted "Current remote graph has been deleted"
+        :file-sync/graph-deleted "The current remote graph has been deleted"
         }
 
    :de {:help/about "Über Logseq"
@@ -520,7 +523,6 @@
         :graph-view "Graph anzeigen"
         :more "Mehr"
         :no "Nein"
-        :open-new-window "Neues Fenster"
         :page-search "In der aktuellen Seite suchen"
         :plugins "Plugins"
         :re-index-detail "Graph neu aufbauen"
@@ -667,7 +669,6 @@
         :on "Aan"
         :open "Open"
         :open-a-directory "Open een lokale map"
-        :open-new-window "Nieuwe venster"
         :or "of"
         :page-search "Zoek in de huidige pagina"
         :parsing-files "Bestanden analyseren"
@@ -812,7 +813,6 @@
         :page/delete-success "Pagina {1} is succesvol verwijderd!"
         :page/earlier "Eerder"
         :page/edit-properties-placeholder "Eigenschappen"
-        :page/file-sync-versions "Pagina versie"
         :page/hide-name "Verberg pagina naam"
         :page/last-modified "Laatst aangepast op"
         :page/make-private "Maak prive"
@@ -1205,7 +1205,6 @@
            :page/make-public "导出 HTML 时发布本页面"
            :page/version-history "查看页面历史记录"
            :page/open-backup-directory "打开页面备份文件夹"
-           :page/file-sync-versions "页面历史"
            :page/make-private "导出 HTML 时取消发布本页面"
            :page/delete "删除本页"
            :page/add-to-favorites "添加收藏"
@@ -1303,7 +1302,6 @@
            :re-index-detail "重新建立索引"
            :re-index-multiple-windows-warning "在重建当前图谱索引前,你需要先关闭其它窗口"
            :re-index-discard-unsaved-changes-warning "重建索引将丢弃当前图谱,之后重新导入保存在磁盘上的所有文件。此操作将丢弃未保存的更改,同时可能需要一段时间。是否继续?"
-           :open-new-window "打开新窗口"
            :sync-from-local-files "刷新(读取本地最新文件)"
            :sync-from-local-files-detail "读取本地最新文件"
            :sync-from-local-changes-detected "执行刷新操作将会导入磁盘上修改过的、或是与实际Logseq页面内容不同的文件。是否继续?"
@@ -1327,6 +1325,9 @@
            :graph/save "保存中……"
            :graph/save-success "保存成功"
            :graph/save-error "保存失败"
+           :graph/all-graphs "所有图谱"
+           :graph/local-graphs "本地图谱"
+           :graph/remote-graphs "同步图谱"
            :graph-view "全局图谱"
            :cards-view "卡片组"
            :all-journals "日记"
@@ -1819,7 +1820,6 @@
         :page/make-public "Hacer pública al publicar"
         :page/version-history "Revisar el historial de la página"
         :page/open-backup-directory "Abrir el directorio de copia de seguridad de la página"
-        :page/file-sync-versions "Versiones de la página"
         :page/make-private "Hacer privada"
         :page/delete "Eliminar página"
         :page/add-to-favorites "Añadir a Favoritos"
@@ -1931,7 +1931,6 @@
         :re-index-detail "Reconstruir el grafo"
         :re-index-multiple-windows-warning "Debe cerrar las otras ventanas antes de reindexar este grafo."
         :re-index-discard-unsaved-changes-warning "Al reindexar se descartará el grafo actual y se procesarán nuevamente todos los archivos según como están actualmente almacenados en disco. Perderá los cambios no guardados y puede tardar un poco. ¿Continuar?"
-        :open-new-window "Nueva ventana"
         :sync-from-local-files "Refrescar"
         :sync-from-local-files-detail "Importar cambios de los archivos locales"
         :sync-from-local-changes-detected "Refrescar detecta y procesa los archivos modificados en su disco que difieren del contenido actual de la página en Logseq. ¿Continuar?"
@@ -2236,7 +2235,6 @@
            :delete "Slett"
            :re-index "Indekser på nytt"
            :re-index-detail "Bygg grafen på nytt"
-           :open-new-window "Nytt vindu"
            :sync-from-local-files "Oppfrisk"
            :sync-from-local-files-detail "Importer endringer fra lokale filer"
            :unlink "koble fra"
@@ -2367,7 +2365,6 @@
            :graph/save-error "Lagring feilet"
            :graph/save-success "Lagring vellykket"
            :page/copy-page-url "Kopier side URL"
-           :page/file-sync-versions "Versjoner av siden"
            :page/open-backup-directory "Åpne mappe med sidens sikkerhetskopier"
            :plugin/not-installed "Ikke installert"
            :settings-page/edit-export-css "Rediger export.css"
@@ -2595,7 +2592,6 @@
            :re-index-detail "Re-indexar gráfico"
            :open "Abrir"
            :open-a-directory "Abrir uma pasta local"
-           :open-new-window "Nova janela"
            :user/delete-account "Excluir conta"
            :user/delete-your-account "Apague a sua conta"
            :user/delete-account-notice "Todas as suas páginas publicadas em logseq.com serão apagadas."
@@ -2686,7 +2682,6 @@
            :file-sync/graph-deleted "O gráfico remoto atual foi excluído"
 
            :page/copy-page-url "Copiar URL da página"
-           :page/file-sync-versions "Versões da página"
            :plugin/not-installed "Não instalado"
            :tutorial/dummy-notes "dummy-notes-en.md"
            :tutorial/text "tutorial-en.md"
@@ -2871,7 +2866,6 @@
            :delete "Apagar"
            :re-index "Re-indexar"
            :re-index-detail "Reconstruir o grafo"
-           :open-new-window "Nova Janela"
            :sync-from-local-files "Atualizar"
            :sync-from-local-files-detail "Importar alterações de ficheiros locais"
            :unlink "remover ligação"
@@ -3005,7 +2999,6 @@
         :graph/save-error "Falha ao salvar"
 
         :page/copy-page-url "Copiar URL da página"
-        :page/file-sync-versions "Versões da Página"
         :page/open-backup-directory "Abra a listagem de backups de página"
         :page/unfavorite "Desfavoritar página"
         :plugin/not-installed "Não instalado"
@@ -3104,7 +3097,6 @@
         :page/make-public "Сделать доступной для публикации"
         :page/version-history "Проверить историю страницы"
         :page/open-backup-directory "Открыть каталог резервных копий"
-        :page/file-sync-versions "Версии страницы"
         :page/make-private "Сделать приватной"
         :page/delete "Удалить страницу"
         :page/add-to-favorites "Добавить в Избранное"
@@ -3220,7 +3212,6 @@
         :sync-from-local-files "Обновить"
         :sync-from-local-changes-detected "При обновлении будут найдены и обработаны файлы, изменённые на диске и отличающиеся от текущего содержимого страниц Logseq. Продолжить?"
         :sync-from-local-files-detail "Импортировать изменения из локальных файлов"
-        :open-new-window "Новое окно"
         :unlink "отвязать"
         :search/publishing "Искать"
         :search "Искать или создать страницу"
@@ -3434,7 +3425,6 @@
         :page/make-public "パブリッシュのため公開する"
         :page/version-history "ページ履歴の確認"
         :page/open-backup-directory "ページのバックアップディレクトリを開く"
-        :page/file-sync-versions "Page versions"
         :page/make-private "非公開にする"
         :page/delete "ページ削除"
         :page/add-to-favorites "お気に入りへ追加"
@@ -3543,7 +3533,6 @@
         :re-index-detail "グラフ再構築"
         :re-index-multiple-windows-warning "このグラフのインデックスを再構築する前に、Logseq で開いている他のウィンドウを閉じる必要があります。"
         :re-index-discard-unsaved-changes-warning "インデックスの再構築は現在のグラフをいったん破棄し、現在ディスク上にある全てのファイルから再構築します。未保存の内容は失われます。また、少し時間がかかります。実行してもよいですか?"
-        :open-new-window "新規ウィンドウ"
         :sync-from-local-files "再表示"
         :sync-from-local-files-detail "ローカルファイルの変更点をインポート"
         :sync-from-local-changes-detected " 再表示は、ディスク上で変更されて Logseq 上のページと内容が変わってしまったファイルを検出し、読み込みます。実行してもよいですか?"
@@ -3757,7 +3746,6 @@
         :page/make-public "Segna come pubblico per la pubblicazione"
         :page/version-history "Controlla la cronologia della pagina"
         :page/open-backup-directory "Apri la cartella dei backup delle pagine"
-        :page/file-sync-versions "Versioni delle pagine"
         :page/make-private "Segna come privato"
         :page/delete "Elimina pagina"
         :page/add-to-favorites "Aggiungi ai Preferiti"
@@ -3865,7 +3853,6 @@
         :re-index-detail "Ricostruisci il grafo"
         :re-index-multiple-windows-warning "È necessario chiudere le altre finestre prima di reindicizzare questo grafo."
         :re-index-discard-unsaved-changes-warning "La reindicizzazione elimina il grafo corrente, quindi elabora nuovamente tutti i file poiché sono attualmente archiviati su disco. Perderai le modifiche non salvate e potrebbe volerci del tempo. Continuare?"
-        :open-new-window "Nuova finestra"
         :sync-from-local-files "Ricarica"
         :sync-from-local-files-detail "Importa cambiamenti da un file locale"
         :sync-from-local-changes-detected "Il ricaricamento rileva ed elabora i file modificati sul disco e divergenti dal contenuto effettivo della pagina Logseq. Continuare?"
@@ -4085,7 +4072,6 @@
         :page/make-public "Yayımlamak için herkese açık hale getir"
         :page/version-history "Sayfa geçmişini kontrol et"
         :page/open-backup-directory "Sayfa yedekleme dizinini aç"
-        :page/file-sync-versions "Sayfa sürümleri"
         :page/make-private "Özel yap"
         :page/delete "Sayfayı sil"
         :page/add-to-favorites "Sık kullanılanlara ekle"
@@ -4198,7 +4184,6 @@
         :re-index-detail "Grafiği yeniden oluştur"
         :re-index-multiple-windows-warning "Bu grafik için yeniden dizin oluşturmadan önce diğer pencereleri kapatmanız gerekiyor."
         :re-index-discard-unsaved-changes-warning "Yeniden dizin oluşturmak mevcut grafiği siler ve ardından tüm dosyaları o anda diskte depolandıkları şekilde yeniden işler. Kaydedilmemiş değişiklikleri kaybedeceksiniz ve bu biraz zaman alabilir. Devam edilsin mi?"
-        :open-new-window "Yeni pencere"
         :sync-from-local-files "Yenile"
         :sync-from-local-files-detail "Yerel dosyalardan değişiklikleri içeri aktarın"
         :sync-from-local-changes-detected "Yenile, diskinizde değiştirilen ve gerçek Logseq sayfa içeriğinden ayrılan dosyaları algılar ve işler. Devam edilsin mi?"
@@ -4414,7 +4399,6 @@
         :page/make-public "출판 전 공개 상태로 만들기"
         :page/version-history "페이지 편집 기록 확인"
         :page/open-backup-directory "페이지 백업 디렉토리 열기"
-        :page/file-sync-versions "페이지 버전"
         :page/make-private "비공개 상태로 만들기"
         :page/delete "페이지 삭제"
         :page/add-to-favorites "즐겨찾기에 추가"
@@ -4525,7 +4509,6 @@
         :re-index-detail "그래프 다시 빌드"
         :re-index-multiple-windows-warning "그래프를 다시 인덱싱 전 다른 윈도우를 모두 닫아야 합니다."
         :re-index-discard-unsaved-changes-warning "인덱싱을 다시 하게 되면 현재 나타나 있는 그래프가 사라지며, 하드 디스크에 저장된 파일대로 그래프를 재구성하게 됩니다. 저장되지 않은 변경사항들이 삭제되며 약간의 시간이 걸릴 수 있습니다. 계속하시겠습니까?"
-        :open-new-window "새 창"
         :sync-from-local-files "새로고침"
         :sync-from-local-files-detail "로컬 파일로부터 변경 사항 불러오기"
         :sync-from-local-changes-detected "그래프를 새로 고치면 로컬 디스크에서 Logseq과는 다르게 변경된 파일들을 감지하고 처리합니다. 계속하시겠습니까?"
@@ -4742,7 +4725,6 @@
         :page/make-public "Oznacz jako publiczną"
         :page/version-history "Sprawdź historię zmian"
         :page/open-backup-directory "Otwórz katalog z kopiami bezpieczeństwa"
-        :page/file-sync-versions "Wersje strony"
         :page/make-private "Oznacz jako prywantą"
         :page/delete "Usuń stronę"
         :page/add-to-favorites "Dodaj do ulubionych"
@@ -4852,7 +4834,6 @@
         :re-index-detail "Przebuduj graf od nowa"
         :re-index-multiple-windows-warning "Musisz zamknąć inne okna zanim rozpoczniesz proces ponownej indeksacji"
         :re-index-discard-unsaved-changes-warning "Ponowna indeksacja odrzuci zmiany w obecnym grafie a następnie przejdzie po wszystkich plikach zapisanych na dysku. Utracisz wszystkie niezapisane zmiany a sam proces może trochę potrwać. Kontynuować?"
-        :open-new-window "Nowe okno"
         :sync-from-local-files "Odśwież"
         :sync-from-local-files-detail "Importuj zmiany z lokalnych plików"
         :sync-from-local-changes-detected "Funkcja Odśwież wykrywa i procesuje pliki zmianione na dysku. Wszystkie pliki różniące się od tych w Logseq zostaną ponownie wczytane. Kontynuować?"

+ 26 - 12
src/main/frontend/encrypt.cljs

@@ -1,11 +1,13 @@
 (ns frontend.encrypt
   (:require [logseq.graph-parser.utf8 :as utf8]
             [frontend.db.utils :as db-utils]
+            [frontend.util :as util]
             [frontend.db :as db]
-            [promesa.core :as p]
             [frontend.state :as state]
             [clojure.string :as str]
             [cljs.reader :as reader]
+            [promesa.core :as p]
+            [electron.ipc :as ipc]
             [shadow.loader :as loader]
             [lambdaisland.glogi :as log]))
 
@@ -88,17 +90,29 @@
 
 (defn encrypt-with-passphrase
   [passphrase content]
-  (p/let [_ (loader/load :age-encryption)
-          lazy-encrypt-with-user-passphrase (resolve 'frontend.extensions.age-encryption/encrypt-with-user-passphrase)
-          content (utf8/encode content)
-          encrypted (@lazy-encrypt-with-user-passphrase passphrase content true)]
-    (utf8/decode encrypted)))
+  (cond
+    (util/electron?)
+    (p/let [raw-content (utf8/encode content)
+            encrypted (ipc/ipc "encrypt-with-passphrase" passphrase raw-content)]
+      (utf8/decode encrypted))
+
+    :else
+    (p/let [lazy-encrypt-with-user-passphrase (resolve 'frontend.extensions.age-encryption/encrypt-with-user-passphrase)
+            content (utf8/encode content)
+            encrypted (@lazy-encrypt-with-user-passphrase passphrase content true)]
+      (utf8/decode encrypted))))
 
-;; ;; TODO: What if decryption failed
 (defn decrypt-with-passphrase
   [passphrase content]
-  (p/let [_ (loader/load :age-encryption)
-          lazy-decrypt-with-user-passphrase (resolve 'frontend.extensions.age-encryption/decrypt-with-user-passphrase)
-          content (utf8/encode content)
-          decrypted (lazy-decrypt-with-user-passphrase passphrase content)]
-    (utf8/decode decrypted)))
+  (cond
+    (util/electron?)
+    (p/let [raw-content (utf8/encode content)
+            decrypted (ipc/ipc "decrypt-with-passphrase" passphrase raw-content)]
+      (utf8/decode decrypted))
+
+    :else
+    (p/let [_ (loader/load :age-encryption)
+            lazy-decrypt-with-user-passphrase (resolve 'frontend.extensions.age-encryption/decrypt-with-user-passphrase)
+            content (utf8/encode content)
+            decrypted (lazy-decrypt-with-user-passphrase passphrase content)]
+      (utf8/decode decrypted))))

+ 3 - 2
src/main/frontend/extensions/code.cljs

@@ -209,7 +209,7 @@
 
 (defn render!
   [state]
-  (let [[config id attr _code theme] (:rum/args state)
+  (let [[config id attr _code theme user-options] (:rum/args state)
         default-open? (and (:editor/code-mode? @state/state)
                            (= (:block/uuid (state/get-edit-block))
                               (get-in config [:block :block/uuid])))
@@ -238,7 +238,8 @@
                                                        (editor-handler/edit-block! block :max block-id))))}}
                           (when config/publishing?
                             {:readOnly true
-                             :cursorBlinkRate -1}))
+                             :cursorBlinkRate -1})
+                          user-options)
         editor (when textarea
                  (from-textarea textarea (clj->js cm-options)))]
     (when editor

+ 2 - 2
src/main/frontend/extensions/zotero.cljs

@@ -107,14 +107,14 @@
          :on-click
          (fn []
            (set! (.-scrollTop (.-parentNode (gdom/getElement "zotero-search"))) 0)
-           (go (<! (search-fn prev-search-term prev-page))))))
+           (search-fn prev-search-term prev-page))))
       (when-not (str/blank? next-page)
         (ui/button
          "next"
          :on-click
          (fn []
            (set! (.-scrollTop (.-parentNode (gdom/getElement "zotero-search"))) 0)
-           (go (<! (search-fn prev-search-term next-page))))))]]))
+           (search-fn prev-search-term next-page))))]]))
 
 (rum/defcs user-or-group-setting <
   (rum/local (setting/setting :type-id) ::type-id)

+ 14 - 0
src/main/frontend/fs.cljs

@@ -120,6 +120,20 @@
                [old-path new-path])]
       (protocol/rename! (get-fs old-path) repo old-path new-path))))
 
+(defn copy!
+  [repo old-path new-path]
+  (cond
+    (= old-path new-path)
+    (p/resolved nil)
+
+    :else
+    (let [[old-path new-path]
+          (map #(if (or (util/electron?) (mobile-util/native-platform?))
+                  %
+                  (str (config/get-repo-dir repo) "/" %))
+               [old-path new-path])]
+      (protocol/copy! (get-fs old-path) repo old-path new-path))))
+
 (defn stat
   [dir path]
   (protocol/stat (get-fs dir) dir path))

+ 20 - 5
src/main/frontend/fs/capacitor_fs.cljs

@@ -14,7 +14,7 @@
             [rum.core :as rum]))
 
 (when (mobile-util/native-ios?)
-  (defn iOS-ensure-documents!
+  (defn ios-ensure-documents!
     []
     (.ensureDocuments mobile-util/ios-file-container)))
 
@@ -126,7 +126,10 @@
 (def backup-dir "logseq/bak")
 (defn- get-backup-dir
   [repo-dir path ext]
-  (let [relative-path (-> (string/replace path repo-dir "")
+  (let [path (if (string/starts-with? path "file://")
+               (subs path 7)
+               path)
+        relative-path (-> (string/replace path repo-dir "")
                           (string/replace (str "." ext) ""))]
     (util/safe-path-join repo-dir (str backup-dir "/" relative-path))))
 
@@ -252,7 +255,7 @@
      :webkit-allow-full-screen "webkitallowfullscreen"
      :height "100%"}]])
 
-(defrecord Capacitorfs []
+(defrecord ^:large-vars/cleanup-todo Capacitorfs []
   protocol/Fs
   (mkdir! [_this dir]
     (-> (.mkdir Filesystem
@@ -270,7 +273,10 @@
       (js/console.log result)
       result))
   (readdir [_this dir]                  ; recursive
-    (readdir dir))
+    (let [dir (if-not (string/starts-with? dir "file://")
+                 (str "file://" dir)
+                 dir)]
+      (readdir dir)))
   (unlink! [this repo path _opts]
     (p/let [path (get-file-path nil path)
             repo-url (config/get-local-dir repo)
@@ -283,7 +289,7 @@
       (protocol/mkdir! this recycle-dir)
       (protocol/rename! this repo path new-path)))
   (rmdir! [_this _dir]
-    ;; Too dangerious!!! We'll never implement this.
+    ;; Too dangerous!!! We'll never implement this.
     nil)
   (read-file [_this dir path _options]
     (let [path (get-file-path dir path)]
@@ -307,6 +313,15 @@
                             :to new-path}))])
        (fn [error]
          (log/error :rename-file-failed error)))))
+  (copy! [_this _repo old-path new-path]
+    (let [[old-path new-path] (map #(get-file-path "" %) [old-path new-path])]
+      (p/catch
+       (p/let [_ (.copy Filesystem
+                        (clj->js
+                         {:from old-path
+                          :to new-path}))])
+       (fn [error]
+         (log/error :copy-file-failed error)))))
   (stat [_this dir path]
     (let [path (get-file-path dir path)]
       (p/let [result (.stat Filesystem (clj->js

+ 1 - 0
src/main/frontend/fs/protocol.cljs

@@ -11,6 +11,7 @@
   (read-file [this dir path opts])
   (write-file! [this repo dir path content opts])
   (rename! [this repo old-path new-path])
+  (copy! [this repo old-path new-path])
   (stat [this dir path])
   (open-dir [this ok-handler])
   (get-files [this path-or-handle ok-handler])

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 786 - 281
src/main/frontend/fs/sync.cljs


+ 7 - 33
src/main/frontend/handler.cljs

@@ -1,15 +1,13 @@
 (ns frontend.handler
   (:require [cljs.reader :refer [read-string]]
-            [clojure.string :as string]
             [electron.ipc :as ipc]
             [electron.listener :as el]
             [frontend.components.page :as page]
             [frontend.components.reference :as reference]
             [frontend.config :as config]
-            [frontend.context.i18n :as i18n :refer [t]]
+            [frontend.context.i18n :as i18n]
             [frontend.db :as db]
             [frontend.db.conn :as conn]
-            [frontend.db.persist :as db-persist]
             [frontend.db.react :as react]
             [frontend.error :as error]
             [frontend.extensions.srs :as srs]
@@ -25,16 +23,16 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.instrumentation.core :as instrument]
             [frontend.modules.outliner.datascript :as outliner-db]
+            [frontend.modules.outliner.file :as file]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.state :as state]
             [frontend.storage :as storage]
-            [frontend.ui :as ui]
             [frontend.util :as util]
             [frontend.util.persist-var :as persist-var]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
-            [logseq.db.schema :as db-schema]
-            [promesa.core :as p]))
+            [promesa.core :as p]
+            [logseq.db.schema :as db-schema]))
 
 (defn set-global-error-notification!
   []
@@ -58,7 +56,6 @@
               (when (and (not (state/nfs-refreshing?))
                          (not (contains? (:file/unlinked-dirs @state/state)
                                          (config/get-repo-dir repo))))
-
                 ;; Don't create the journal file until user writes something
                 (page-handler/create-today-journal!))))]
     (f)
@@ -115,7 +112,6 @@
 
          (watch-for-date!)
          (file-handler/watch-for-current-graph-dir!)
-         (state/pub-event! [:graph/ready (state/get-current-repo)])
          (state/pub-event! [:graph/restored (state/get-current-repo)])))
       (p/catch (fn [error]
                  (log/error :exception error)))))
@@ -158,30 +154,6 @@
               (js/window.location.reload)))
      2000)))
 
-(defn- get-repos
-  []
-  (p/let [nfs-dbs (db-persist/get-all-graphs)]
-    ;; TODO: Better IndexDB migration handling
-    (cond
-      (and (mobile-util/native-platform?)
-           (some #(or (string/includes? % " ")
-                      (string/includes? % "logseq_local_/")) nfs-dbs))
-      (do (notification/show! ["DB version is not compatible, please clear cache then re-add your graph back."
-                               (ui/button
-                                 (t :settings-page/clear-cache)
-                                 :class    "ui__modal-enter"
-                                 :class    "text-sm p-1"
-                                 :on-click clear-cache!)] :error false)
-          {:url config/local-repo
-           :example? true})
-
-      (seq nfs-dbs)
-      (map (fn [db] {:url db :nfs? true}) nfs-dbs)
-
-      :else
-      [{:url config/local-repo
-        :example? true}])))
-
 (defn- register-components-fns!
   []
   (state/set-page-blocks-cp! page/page-blocks-cp)
@@ -210,19 +182,21 @@
 
     (events/run!)
 
-    (p/let [repos (get-repos)]
+    (p/let [repos (repo-handler/get-repos)]
       (state/set-repos! repos)
       (restore-and-setup! repos db-schema))
     (when (mobile-util/native-platform?)
       (p/do! (mobile-util/hide-splash)))
 
     (db/run-batch-txs!)
+    (file/<ratelimit-file-writes!)
 
     (when config/dev?
       (enable-datalog-console))
     (when (util/electron?)
       (el/listen!))
     (persist-var/load-vars)
+    (user-handler/restore-tokens-from-localstorage)
     (user-handler/refresh-tokens-loop)
     (js/setTimeout instrument! (* 60 1000))))
 

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

@@ -1,7 +1,7 @@
 (ns frontend.handler.editor.lifecycle
   (:require [frontend.handler.editor :as editor-handler :refer [get-state]]
             [frontend.handler.editor.keyboards :as keyboards-handler]
-            [frontend.state :as state]
+            [frontend.state :as state :refer [sub]]
             [frontend.util :as util]
             [goog.dom :as gdom]))
 
@@ -18,6 +18,10 @@
     ;; which will hide the editor so no way for editing.
     (js/setTimeout #(keyboards-handler/esc-save! state) 100)
 
+    ;; try to close all opened dropdown menu
+    (when-let [close-fns (vals (sub :modal/dropdowns))]
+      (try (doseq [f close-fns] (f)) (catch js/Error _e ())))
+
     (when-let [element (gdom/getElement id)]
       (.focus element)
       (js/setTimeout #(util/scroll-editor-cursor element) 50)))

+ 151 - 28
src/main/frontend/handler/events.cljs

@@ -2,12 +2,12 @@
   (:refer-clojure :exclude [run!])
   (:require ["@capacitor/filesystem" :refer [Directory Filesystem]]
             [clojure.core.async :as async]
+            [clojure.core.async.interop :refer [p->c]]
             [clojure.set :as set]
             [clojure.string :as string]
             [datascript.core :as d]
             [frontend.commands :as commands]
             [frontend.components.diff :as diff]
-            [frontend.components.encryption :as encryption]
             [frontend.components.git :as git-component]
             [frontend.components.plugins :as plugin]
             [frontend.components.search :as search]
@@ -35,6 +35,7 @@
             [frontend.handler.route :as route-handler]
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.handler.web.nfs :as nfs-handler]
             [frontend.mobile.core :as mobile]
             [frontend.mobile.util :as mobile-util]
@@ -46,6 +47,9 @@
             [frontend.ui :as ui]
             [frontend.util :as util]
             [frontend.util.persist-var :as persist-var]
+            [frontend.handler.file-sync :as file-sync-handler]
+            [frontend.components.file-sync :as file-sync]
+            [frontend.components.encryption :as encryption]
             [goog.dom :as gdom]
             [logseq.db.schema :as db-schema]
             [promesa.core :as p]
@@ -55,6 +59,39 @@
 
 (defmulti handle first)
 
+(defn- file-sync-restart! []
+  (async/go (async/<! (p->c (persist-var/load-vars)))
+            (async/<! (sync/<sync-stop))
+            (some-> (sync/sync-start) async/<!)))
+
+(defn- file-sync-stop! []
+  (async/go (async/<! (p->c (persist-var/load-vars)))
+            (async/<! (sync/<sync-stop))))
+
+(defmethod handle :user/login [[_]]
+  (state/set-state! [:ui/loading? :login] false)
+  (async/go
+    (let [result (async/<! (sync/<user-info sync/remoteapi))]
+      (when (seq result)
+        (state/set-state! :user/info result)
+
+        (let [status (if (user-handler/alpha-user?) :welcome :unavailable)]
+          (when (= status :welcome)
+            (async/<! (file-sync-handler/load-session-graphs))
+            (p/let [repos (repo-handler/refresh-repos!)]
+              (when-let [repo (state/get-current-repo)]
+                (when (some #(and (= (:url %) repo)
+                                 (vector? (:sync-meta %))
+                                 (util/uuid-string? (first (:sync-meta %)))
+                                 (util/uuid-string? (second (:sync-meta %)))) repos)
+                 (file-sync-restart!)))))
+          (file-sync/maybe-onboarding-show status))))))
+
+(defmethod handle :user/logout [[_]]
+  (file-sync-handler/reset-session-graphs)
+  (sync/remove-all-pwd!)
+  (file-sync-handler/reset-user-state!))
+
 (defmethod handle :graph/added [[_ repo {:keys [empty-graph?]}]]
   (db/set-key-value repo :ast/version db-schema/ast-version)
   (search-handler/rebuild-indices!)
@@ -63,13 +100,9 @@
   (when (state/setups-picker?)
     (if empty-graph?
       (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)
-         (sync/sync-start)
-))
+      (route-handler/redirect-to-home!)))
+  (repo-handler/refresh-repos!)
+  (file-sync-stop!))
 
 (defn- graph-switch
   ([graph]
@@ -88,8 +121,8 @@
          (fs/watch-dir! dir-name))
        (srs/update-cards-due-count!)
        (state/pub-event! [:graph/ready graph])
-
-       (file-sync-stop-when-switch-graph)))))
+       (repo-handler/refresh-repos!)
+       (file-sync-restart!)))))
 
 (def persist-db-noti-m
   {:before     #(notification/show!
@@ -102,23 +135,34 @@
 (defn- graph-switch-on-persisted
   "Logic for keeping db sync when switching graphs
    Only works for electron"
-  [graph]
+  [graph {:keys [persist?]}]
   (let [current-repo (state/get-current-repo)]
     (p/do!
-     (when (util/electron?)
-       (p/do!
-        (repo-handler/persist-db! current-repo persist-db-noti-m)
-        (repo-handler/broadcast-persist-db! graph)))
+     (when persist?
+       (when (util/electron?)
+         (p/do!
+          (repo-handler/persist-db! current-repo persist-db-noti-m)
+          (repo-handler/broadcast-persist-db! graph))))
      (repo-handler/restore-and-setup-repo! graph)
      (graph-switch graph))))
 
-(defmethod handle :graph/switch [[_ graph]]
-  (if (outliner-file/writes-finished?)
-    (graph-switch-on-persisted graph)
+(defmethod handle :graph/switch [[_ graph opts]]
+  (if @outliner-file/*writes-finished?
+    (graph-switch-on-persisted graph opts)
     (notification/show!
      "Please wait seconds until all changes are saved for the current graph."
      :warning)))
 
+(defmethod handle :graph/pick-dest-to-sync [[_ graph]]
+  (state/set-modal!
+   (file-sync/pick-dest-to-sync-panel graph)
+   {:center? true}))
+
+(defmethod handle :graph/pick-page-histories [[_ graph-uuid page-name]]
+  (state/set-modal!
+   (file-sync/pick-page-histories-panel graph-uuid page-name)
+   {:id :page-histories :label "modal-page-histories"}))
+
 (defmethod handle :graph/open-new-window [[ev repo]]
   (p/let [current-repo (state/get-current-repo)
           target-repo (or repo current-repo)
@@ -327,8 +371,10 @@
         (set! (.. right-sidebar-node -style -paddingBottom) (str (+ 150 keyboard-height) "px")))
       (when-let [card-preview-el (js/document.querySelector ".cards-review")]
         (set! (.. card-preview-el -style -marginBottom) (str keyboard-height "px")))
+      (when-let [card-preview-el (js/document.querySelector ".encryption-password")]
+        (set! (.. card-preview-el -style -marginBottom) (str keyboard-height "px")))
       (js/setTimeout (fn []
-                       (let [toolbar (.querySelector main-node "#mobile-editor-toolbar")]
+                       (when-let [toolbar (.querySelector main-node "#mobile-editor-toolbar")]
                          (set! (.. toolbar -style -bottom) (str keyboard-height "px"))))
                      100))))
 
@@ -341,6 +387,8 @@
     (when (mobile-util/native-ios?)
       (when-let [card-preview-el (js/document.querySelector ".cards-review")]
         (set! (.. card-preview-el -style -marginBottom) "0px"))
+      (when-let [card-preview-el (js/document.querySelector ".encryption-password")]
+        (set! (.. card-preview-el -style -marginBottom) "0px"))
       (set! (.. main-node -style -marginBottom) "0px")
       (when-let [left-sidebar-node (gdom/getElement "left-sidebar")]
         (set! (.. left-sidebar-node -style -bottom) "0px"))
@@ -372,9 +420,11 @@
 
 (defmethod handle :validate-appId [[_ graph-switch-f graph]]
   (when-let [deprecated-repo (or graph (state/get-current-repo))]
-    ;; Installation is will not be changed for iCloud
+    ;; Installation is not changed for iCloud
     (if (mobile-util/iCloud-container-path? deprecated-repo)
-      (when graph-switch-f (graph-switch-f graph true))
+      (when graph-switch-f
+        (graph-switch-f graph true)
+        (state/pub-event! [:graph/ready (state/get-current-repo)]))
       (p/let [deprecated-app-id (get-ios-app-id deprecated-repo)
               current-document-url (.getUri Filesystem #js {:path ""
                                                             :directory (.-Documents Directory)})
@@ -383,6 +433,7 @@
         (if (= deprecated-app-id current-app-id)
           (when graph-switch-f (graph-switch-f graph true))
           (do
+            (file-sync-stop!)
             (.unwatch mobile-util/fs-watcher)
             (let [current-repo (string/replace deprecated-repo deprecated-app-id current-app-id)
                   current-repo-dir (config/get-repo-dir current-repo)]
@@ -399,7 +450,9 @@
               (db/persist-if-idle! current-repo)
               (file-handler/restore-config! current-repo)
               (.watch mobile-util/fs-watcher #js {:path current-repo-dir})
-              (when graph-switch-f (graph-switch-f current-repo true)))))))))
+              (when graph-switch-f (graph-switch-f current-repo true))
+              (file-sync-restart!))))
+        (state/pub-event! [:graph/ready (state/get-current-repo)])))))
 
 (defmethod handle :plugin/consume-updates [[_ id pending? updated?]]
   (let [downloading? (:plugin/updates-downloading? @state/state)]
@@ -460,15 +513,32 @@
 (defmethod handle :file-watcher/changed [[_ ^js event]]
   (let [type (.-event event)
         payload (-> event
-                    (js->clj :keywordize-keys true))
-        ;; TODO: remove this
-        payload' (-> payload (update :path js/decodeURI))]
+                    (js->clj :keywordize-keys true))]
     (fs-watcher/handle-changed! type payload)
-    (sync/file-watch-handler type payload')))
+    (when config/enable-file-sync?
+     (sync/file-watch-handler type payload))))
 
 (defmethod handle :rebuild-slash-commands-list [[_]]
   (page-handler/rebuild-slash-commands-list!))
 
+(defn- refresh-cb []
+  (page-handler/create-today-journal!)
+  (st/refresh!)
+  (file-sync-restart!))
+
+(defmethod handle :graph/ask-for-re-fresh [_]
+  (handle
+   [:modal/show
+    [:div {:style {:max-width 700}}
+     [:p (t :sync-from-local-changes-detected)]
+     (ui/button
+      (t :yes)
+      :autoFocus "on"
+      :large? true
+      :on-click (fn []
+                  (state/close-modal!)
+                  (nfs-handler/refresh! (state/get-current-repo) refresh-cb)))]]))
+
 (defmethod handle :graph/ask-for-re-index [[_ *multiple-windows?]]
   (if (and (util/atom? *multiple-windows?) @*multiple-windows?)
     (handle
@@ -488,7 +558,9 @@
                      (state/close-modal!)
                      (repo-handler/re-index!
                       nfs-handler/rebuild-index!
-                      page-handler/create-today-journal!)))]])))
+                      #(do
+                         (page-handler/create-today-journal!)
+                         (file-sync-restart!)))))]])))
 
 ;; encryption
 (defmethod handle :modal/encryption-setup-dialog [[_ repo-url close-fn]]
@@ -502,6 +574,16 @@
     db-encrypted-secret
     close-fn)))
 
+(defmethod handle :modal/remote-encryption-input-pw-dialog [[_ repo-url remote-graph-info type opts]]
+  (state/set-modal!
+   (encryption/input-password
+    repo-url nil (merge
+                  (assoc remote-graph-info
+                         :type (or type :create-pwd-remote)
+                         :repo repo-url)
+                  opts))
+   {:center? true :close-btn? false :close-backdrop? false}))
+
 (defmethod handle :journal/insert-template [[_ page-name]]
   (let [page-name (util/page-name-sanity-lc page-name)]
     (when-let [page (db/pull [:block/name page-name])]
@@ -512,8 +594,49 @@
            template
            {:target page}))))))
 
+(defmethod handle :file-sync-graph/restore-file [[_ graph page-entity content]]
+  (when (db/get-db graph)
+    (let [file (:block/file page-entity)]
+      (when-let [path (:file/path file)]
+        (when (not= content (:file/content file))
+          (sync/add-new-version-file graph path (:file/content file)))
+        (p/let [_ (file-handler/alter-file graph
+                                           path
+                                           content
+                                           {:re-render-root? true
+                                            :skip-compare? true})]
+          (state/close-modal!)
+          (route-handler/redirect! {:to :page
+                                    :path-params {:name (:block/name page-entity)}}))))))
+
+
+(defmethod handle :file-sync/onboarding-tip [[_ type opts]]
+  (let [type (keyword type)]
+    (state/set-modal!
+     (file-sync/make-onboarding-panel type)
+     (merge {:close-btn?      false
+             :center?         true
+             :close-backdrop? (not= type :welcome)} opts))))
+
+(defmethod handle :file-sync/maybe-onboarding-show [[_ type]]
+  (file-sync/maybe-onboarding-show type))
+
+(defmethod handle :file-sync/storage-exceed-limit [[_]]
+  (notification/show! "file sync storage exceed limit" :warning false)
+  (file-sync-stop!))
+
+(defmethod handle :file-sync/graph-count-exceed-limit [[_]]
+  (notification/show! "file sync graph count exceed limit" :warning false)
+  (file-sync-stop!))
+
+(defmethod handle :file-sync/restart [[_]]
+  (file-sync-restart!))
+
 (defmethod handle :graph/restored [[_ _graph]]
-  (mobile/init!))
+  (mobile/init!)
+  (when-not (mobile-util/native-ios?)
+    (state/pub-event! [:graph/ready (state/get-current-repo)])))
+
 (defmethod handle :graph/dir-gone [[_ dir]]
   (state/pub-event! [:notification/show
                      {:content (str "The directory " dir " has been renamed or deleted, the editor will be disabled for this graph, you can unlink the graph.")

+ 113 - 92
src/main/frontend/handler/file_sync.cljs

@@ -1,48 +1,62 @@
 (ns frontend.handler.file-sync
   (:require ["path" :as path]
-            [cljs-time.coerce :as tc]
             [cljs-time.format :as tf]
             [cljs.core.async :as async :refer [go <!]]
             [cljs.core.async.interop :refer [p->c]]
             [clojure.string :as string]
-            [clojure.set :as set]
             [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.handler.user :as user]
             [frontend.fs :as fs]))
 
+(def *beta-unavailable? (volatile! false))
+
 (def hiding-login&file-sync (not config/enable-file-sync?))
 (def refresh-file-sync-component (atom false))
 
-(defn graph-txid-exists?
+(defn current-graph-sync-on?
   []
-  (let [[_user-uuid graph-uuid _txid] @sync/graphs-txid]
-    (some? graph-uuid)))
+  (when-let [sync-state (state/sub [:file-sync/sync-state (state/get-current-repo)])]
+    (not (sync/sync-state--stopped? sync-state))))
 
+(defn synced-file-graph?
+  [graph]
+  (some (fn [item] (and (= graph (:url item))
+                        (:GraphUUID item))) (state/get-repos)))
 
 (defn create-graph
   [name]
   (go
-    (let [r* (<! (sync/create-graph sync/remoteapi name))
+    (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
-          (sync/update-graphs-txid! 0 r (user/user-uuid) (state/get-current-repo))
-          (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
+        (let [tx-info [0 r (user/user-uuid) (state/get-current-repo)]]
+          (apply sync/update-graphs-txid! tx-info)
+          (swap! refresh-file-sync-component not) tx-info)
+        (cond
+          ;; already processed this exception by events
+          ;; - :file-sync/storage-exceed-limit
+          ;; - :file-sync/graph-count-exceed-limit
+          (or (sync/storage-exceed-limit? r)
+              (sync/graph-count-exceed-limit? r))
+          nil
+
+          (contains? #{400 404} (get-in (ex-data r) [:err :status]))
+          (notification/show! (str "Create graph failed: already existed graph: " name) :warning true nil 4000)
+
+          :else
+          (notification/show! (str "Create graph failed:" r) :warning true nil 4000))))))
+
+(defn <delete-graph
   [graph-uuid]
-  (sync/sync-stop)
   (go
-    (let [r (<! (sync/delete-graph sync/remoteapi graph-uuid))]
+    (when (= graph-uuid @sync/graphs-txid)
+      (<! (sync/<sync-stop)))
+    (let [r (<! (sync/<delete-graph sync/remoteapi graph-uuid))]
       (if (instance? ExceptionInfo r)
         (notification/show! (str "Delete graph failed: " graph-uuid) :warning)
         (let [[_ local-graph-uuid _] @sync/graphs-txid]
@@ -53,57 +67,42 @@
 
 (defn list-graphs
   []
-  (go (:Graphs (<! (sync/list-remote-graphs sync/remoteapi)))))
+  (go (:Graphs (<! (sync/<list-remote-graphs sync/remoteapi)))))
 
-(defn download-all-files
-  [repo graph-uuid user-uuid base-path]
-  (go
-    (state/reset-file-sync-download-init-state!)
-    (state/set-file-sync-download-init-state! {:total js/NaN :finished 0 :downloading? true})
-    (let [remote-all-files-meta (<! (sync/get-remote-all-files-meta sync/remoteapi graph-uuid))
-          local-all-files-meta (<! (sync/get-local-all-files-meta sync/rsapi graph-uuid base-path))
-          diff-remote-files (set/difference remote-all-files-meta local-all-files-meta)
-          latest-txid (:TXId (<! (sync/get-remote-graph sync/remoteapi nil graph-uuid)))
-          partitioned-filetxns
-          (sequence (sync/filepaths->partitioned-filetxns 10 graph-uuid user-uuid)
-                    (map sync/relative-path diff-remote-files))]
-      (state/set-file-sync-download-init-state! {:total (count diff-remote-files) :finished 0})
-      (let [r (<! (sync/apply-filetxns-partitions
-                   nil user-uuid graph-uuid base-path partitioned-filetxns repo nil (atom false)
-                   (fn [filetxns]
-                     (state/set-file-sync-download-init-state!
-                      {:downloading-files (mapv sync/relative-path filetxns)}))
-                   (fn [filetxns]
-                     (state/set-file-sync-download-init-state!
-                      {:finished (+ (count filetxns)
-                                    (or (:finished (state/get-file-sync-download-init-state)) 0))}))))]
-        (if (instance? ExceptionInfo r)
-          ;; TODO: add re-download button
-          (notification/show! (str "Download graph failed: " (ex-cause r)) :warning)
-          (do (state/reset-file-sync-download-init-state!)
-              (sync/update-graphs-txid! latest-txid graph-uuid user-uuid repo)))))))
-
-(defn switch-graph [graph-uuid]
+(defn load-session-graphs
+  []
+  (when-not (state/sub [:file-sync/remote-graphs :loading])
+    (go (state/set-state! [:file-sync/remote-graphs :loading] true)
+        (let [graphs (<! (list-graphs))]
+          (state/set-state! :file-sync/remote-graphs {:loading false :graphs graphs})))))
+
+(defn reset-session-graphs
+  []
+  (state/set-state! :file-sync/remote-graphs {:loading false :graphs nil}))
+
+(defn init-graph [graph-uuid]
   (let [repo (state/get-current-repo)
-        base-path (config/get-repo-dir repo)
         user-uuid (user/user-uuid)]
     (sync/update-graphs-txid! 0 graph-uuid user-uuid repo)
-    (download-all-files repo graph-uuid user-uuid base-path)
-    (swap! refresh-file-sync-component not)))
+    (swap! refresh-file-sync-component not)
+    (state/pub-event! [:graph/switch repo {:persist? false}])))
 
-(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))
-      (when-not (instance? ExceptionInfo r)
-        key))))
+(defn download-version-file
+  ([graph-uuid file-uuid version-uuid]
+   (download-version-file graph-uuid file-uuid version-uuid false))
+  ([graph-uuid file-uuid version-uuid silent-download?]
+   (go
+     (let [key (path/join file-uuid version-uuid)
+           r   (<! (sync/<download-version-files
+                    sync/rsapi graph-uuid (config/get-repo-dir (state/get-current-repo)) [key]))]
+       (if (instance? ExceptionInfo r)
+         (notification/show! (ex-cause r) :error)
+         (when-not silent-download?
+           (notification/show! [:div
+                                [:div "Downloaded version file at: "]
+                                [:div key]] :success false)))
+       (when-not (instance? ExceptionInfo r)
+         (path/join "logseq" "version-files" key))))))
 
 (defn- list-file-local-versions
   [page]
@@ -111,7 +110,7 @@
     (when-let [path (-> page :block/file :file/path)]
       (let [base-path           (config/get-repo-dir (state/get-current-repo))
             rel-path            (string/replace-first path base-path "")
-            version-files-dir   (->> (path/join "version-files/local" rel-path)
+            version-files-dir   (->> (path/join "logseq/version-files/local" rel-path)
                                      path/parse
                                      (#(js->clj % :keywordize-keys true))
                                      ((juxt :dir :name))
@@ -137,42 +136,64 @@
                  {:create-time create-time :path path :relative-path (string/replace-first path base-path "")}))
              version-file-paths)))))))
 
-(defn list-file-versions [graph-uuid page]
+(defn fetch-page-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 "")]
+            base-path (if (string/starts-with? base-path "file://")
+                        (js/decodeURIComponent base-path)
+                        base-path)
+            path*     (string/replace-first (string/replace-first path base-path "") #"^/" "")]
         (go
           (let [version-list       (:VersionList
-                                    (<! (sync/get-remote-file-versions sync/remoteapi graph-uuid path*)))
+                                    (<! (sync/<get-remote-file-versions sync/remoteapi graph-uuid path*)))
                 local-version-list (<! (list-file-local-versions page))
                 all-version-list   (->> (concat version-list local-version-list)
-                                        (sort-by #(or (tc/from-string (:CreateTime %))
+                                        (sort-by #(or (:CreateTime %)
                                                       (:create-time %))
                                                  >))]
-            (notification/show! [:div
-                                 [:div.font-bold "File history - " path*]
-                                 [:hr.my-2]
-                                 (for [version all-version-list]
-                                   (let [version-uuid (or (:VersionUUID version) (:relative-path version))
-                                         local?       (some? (:relative-path version))]
-                                     [:div.my-4 {:key version-uuid}
-                                      [:div
-                                       [:a.text-xs.inline
-                                        {:on-click #(if local?
-                                                      (js/window.apis.openPath (:path version))
-                                                      (go
-                                                        (let [relative-path
-                                                              (<! (download-version-file graph-uuid
-                                                                                         (:FileUUID version)
-                                                                                         (:VersionUUID version)))]
-                                                          (js/window.apis.openPath (path/join base-path relative-path)))))}
-                                        version-uuid]
-                                       (when-not local?
-                                         [:div.opacity-70 (str "Size: " (:Size version))])]
-                                      [:div.opacity-50
-                                       (util/time-ago (or (tc/from-string (:CreateTime version))
-                                                          (:create-time version)))]]))]
-                                :success false)))))))
+            all-version-list))))))
 
 (defn get-current-graph-uuid [] (second @sync/graphs-txid))
+
+(def *wait-syncing-graph (atom nil))
+
+(defn set-wait-syncing-graph
+  [graph]
+  (reset! *wait-syncing-graph graph))
+
+(defn init-remote-graph
+  [local]
+  (when-let [graph (and local @*wait-syncing-graph)]
+    (notification/show!
+     (str "Start syncing the remote graph "
+          (:GraphName graph)
+          " to "
+          (config/get-string-repo-dir (config/get-local-dir local)))
+     :warning)
+
+    (init-graph (:GraphUUID graph))
+    (state/close-modal!)))
+
+(defn setup-file-sync-event-listeners
+  []
+  (let [c     (async/chan 1)
+        p     sync/sync-events-publication
+        topic :finished-local->remote]
+
+    (async/sub p topic c)
+
+    (async/go-loop []
+      (let [{:keys [data]} (async/<! c)]
+        (when (and (:file-change-events data)
+                   (= :page (state/get-current-route)))
+          (state/pub-event!
+           [:file-sync/maybe-onboarding-show :sync-history])))
+      (recur))
+
+    #(async/unsub p topic c)))
+
+(defn reset-user-state! []
+  (vreset! *beta-unavailable? false)
+  (state/set-state! :file-sync/onboarding-state nil))

+ 18 - 10
src/main/frontend/handler/page.cljs

@@ -194,6 +194,7 @@
     (string/join "/" parts)))
 
 (defn rename-file!
+  "emit file-rename events to :file/rename-event-chan"
   [file new-name ok-handler]
   (let [repo (state/get-current-repo)
         file (db/pull (:db/id file))
@@ -203,7 +204,10 @@
     (db/transact! repo [{:db/id (:db/id file)
                          :file/path new-path}])
     (->
-     (p/let [_ (fs/rename! repo old-path new-path)]
+     (p/let [_ (state/offer-file-rename-event-chan! {:repo repo
+                                                     :old-path old-path
+                                                     :new-path new-path})
+             _ (fs/rename! repo old-path new-path)]
        (ok-handler))
      (p/catch (fn [error]
                 (println "file rename failed: " error))))))
@@ -313,10 +317,12 @@
 (defn unfavorite-page!
   [page-name]
   (when-not (string/blank? page-name)
-    (let [favorites (->> (:favorites (state/get-config))
-                         (remove #(= (string/lower-case %) (string/lower-case page-name)))
-                         (vec))]
-      (config-handler/set-config! :favorites favorites))))
+    (let [old-favorites (:favorites (state/get-config))
+          new-favorites (->> old-favorites
+                             (remove #(= (string/lower-case %) (string/lower-case page-name)))
+                             (vec))]
+      (when-not (= old-favorites new-favorites)
+        (config-handler/set-config! :favorites new-favorites)))))
 
 (defn toggle-favorite! []
   ;; NOTE: in journals or settings, current-page is nil
@@ -666,11 +672,13 @@
           (contains? (set templates) (string/lower-case title)))))))
 
 (defn ls-dir-files!
-  [ok-handler]
-  (web-nfs/ls-dir-files-with-handler!
-   (fn []
-     (init-commands!)
-     (when ok-handler (ok-handler)))))
+  ([ok-handler] (ls-dir-files! ok-handler nil))
+  ([ok-handler opts]
+   (web-nfs/ls-dir-files-with-handler!
+     (fn [e]
+       (init-commands!)
+       (when ok-handler (ok-handler e)))
+     opts)))
 
 (defn get-all-pages
   [repo]

+ 60 - 1
src/main/frontend/handler/repo.cljs

@@ -17,6 +17,7 @@
             [frontend.spec :as spec]
             [frontend.state :as state]
             [frontend.util :as util]
+            [frontend.util.fs :as util-fs]
             [lambdaisland.glogi :as log]
             [promesa.core :as p]
             [shadow.resource :as rc]
@@ -24,8 +25,10 @@
             [logseq.graph-parser.util :as gp-util]
             [logseq.graph-parser :as graph-parser]
             [electron.ipc :as ipc]
+            [cljs-bean.core :as bean]
             [clojure.core.async :as async]
-            [frontend.encrypt :as encrypt]))
+            [frontend.encrypt :as encrypt]
+            [frontend.mobile.util :as mobile-util]))
 
 ;; Project settings should be checked in two situations:
 ;; 1. User changes the config.edn directly in logseq.com (fn: alter-file)
@@ -451,6 +454,62 @@
   (p/let [_ (ipc/ipc "broadcastPersistGraph" graph)] ;; invoke for chaining promise
     nil))
 
+(defn get-repos
+  []
+  (p/let [nfs-dbs (db-persist/get-all-graphs)
+          nfs-dbs (map (fn [db]
+                         {:url db
+                          :root (config/get-local-dir db)
+                          :nfs? true}) nfs-dbs)
+          nfs-dbs (and (seq nfs-dbs)
+                       (cond (util/electron?)
+                             (ipc/ipc :inflateGraphsInfo nfs-dbs)
+
+                             (mobile-util/native-platform?)
+                             (util-fs/inflate-graphs-info nfs-dbs)
+
+                             :else
+                             nil))
+          nfs-dbs (seq (bean/->clj nfs-dbs))]
+    (cond
+      (seq nfs-dbs)
+      nfs-dbs
+
+      :else
+      [{:url config/local-repo
+        :example? true}])))
+
+(defn combine-local-&-remote-graphs
+  [local-repos remote-repos]
+  (when-let [repos' (seq (concat (map #(if-let [sync-meta (seq (:sync-meta %))]
+                                         (assoc % :GraphUUID (second sync-meta)) %)
+                                      local-repos)
+                                 (some->> remote-repos
+                                          (map #(assoc % :remote? true)))))]
+    (let [repos' (group-by :GraphUUID repos')
+          repos'' (mapcat (fn [[k vs]]
+                            (if-not (nil? k)
+                              [(merge (first vs) (second vs))] vs))
+                          repos')]
+      (sort-by (fn [repo]
+                 (let [graph-name (or (:GraphName repo)
+                                      (last (string/split (:root repo) #"/")))]
+                   [(:remote? repo) (string/lower-case graph-name)])) repos''))))
+
+(defn get-detail-graph-info
+  [url]
+  (when-let [graphs (seq (and url (combine-local-&-remote-graphs
+                                    (state/get-repos)
+                                    (state/get-remote-repos))))]
+    (first (filter #(when-let [url' (:url %)]
+                      (= url url')) graphs))))
+
+(defn refresh-repos!
+  []
+  (p/let [repos (get-repos)]
+    (state/set-repos! repos)
+    repos))
+
 (defn graph-ready!
   "Call electron that the graph is loaded."
   [graph]

+ 4 - 0
src/main/frontend/handler/route.cljs

@@ -35,6 +35,10 @@
   []
   (redirect! {:to :graph}))
 
+(defn redirect-to-all-graphs
+  []
+  (redirect! {:to :repos}))
+
 (defn redirect-to-page!
   "Must ensure `page-name` is dereferenced (not an alias), or it will create a wrong new page with that name (#3511)."
   ([page-name]

+ 85 - 32
src/main/frontend/handler/user.cljs

@@ -7,6 +7,7 @@
             [cljs-time.core :as t]
             [cljs-time.coerce :as tc]
             [cljs-http.client :as http]
+            [lambdaisland.glogi :as log]
             [cljs.core.async :as async :refer [go go-loop <! timeout]]))
 
 (defn set-preferred-format!
@@ -26,15 +27,15 @@
 (defn- parse-jwt [jwt]
   (some-> jwt
           (string/split ".")
-          (second)
-          (js/atob)
-          (js/JSON.parse)
+          second
+          js/atob
+          js/JSON.parse
           (js->clj :keywordize-keys true)))
 
 (defn- expired? [parsed-jwt]
   (some->
    (* 1000 (:exp parsed-jwt))
-   (tc/from-long)
+   tc/from-long
    (t/before? (t/now))))
 
 (defn- almost-expired?
@@ -42,62 +43,66 @@
   [parsed-jwt]
   (some->
    (* 1000 (:exp parsed-jwt))
-   (tc/from-long)
+   tc/from-long
    (t/before? (-> 1 t/hours t/from-now))))
 
 (defn email []
   (some->
    (state/get-auth-id-token)
-   (parse-jwt)
-   (:email)))
+   parse-jwt
+   :email))
 
 (defn user-uuid []
   (some->
    (state/get-auth-id-token)
-   (parse-jwt)
-   (:sub)))
+   parse-jwt
+   :sub))
 
 (defn logged-in? []
   (boolean
    (some->
     (state/get-auth-id-token)
-    (parse-jwt)
-    (expired?)
-    (not))))
+    parse-jwt
+    expired?
+    not)))
 
-(defn- clear-tokens []
+(defn- set-token-to-localstorage!
+  ([id-token access-token]
+   (log/info :debug "set-token-to-localstorage!")
+   (js/localStorage.setItem "id-token" id-token)
+   (js/localStorage.setItem "access-token" access-token))
+  ([id-token access-token refresh-token]
+   (log/info :debug "set-token-to-localstorage!")
+   (js/localStorage.setItem "id-token" id-token)
+   (js/localStorage.setItem "access-token" access-token)
+   (js/localStorage.setItem "refresh-token" refresh-token)))
+
+(defn- clear-tokens
+  []
   (state/set-auth-id-token nil)
   (state/set-auth-access-token nil)
-  (state/set-auth-refresh-token nil))
+  (state/set-auth-refresh-token nil)
+  (set-token-to-localstorage! "" "" ""))
+
 
 (defn- set-tokens!
   ([id-token access-token]
    (state/set-auth-id-token id-token)
-   (state/set-auth-access-token access-token))
+   (state/set-auth-access-token access-token)
+   (set-token-to-localstorage! id-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://" config/API-DOMAIN "/auth_callback?code=" code)
-                             {:with-credentials? false}))]
-      (if (= 200 (:status resp))
-        (-> resp
-            :body
-            (as-> $ (set-tokens! (:id_token $) (:access_token $) (:refresh_token $))))
-        (debug/pprint "login-callback" resp)))))
+   (state/set-auth-refresh-token refresh-token)
+   (set-token-to-localstorage! id-token access-token refresh-token)))
 
-(defn logout []
-  (clear-tokens))
 
-(defn refresh-id-token&access-token
+(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"
   []
-  (when-let [refresh-token (state/get-auth-refresh-token)]
-    (go
+  (go
+    (when-let [refresh-token (state/get-auth-refresh-token)]
       (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_refresh_token?refresh_token=" refresh-token)
                                {:with-credentials? false}))]
         (if (= 400 (:status resp))
@@ -113,6 +118,44 @@
              (as-> $ (set-tokens! (:id_token $) (:access_token $))))
             true))))))
 
+(defn restore-tokens-from-localstorage
+  "restore id-token, access-token, refresh-token from localstorage,
+  and refresh id-token&access-token if necessary.
+  return nil when tokens are not available."
+  []
+  (println "restore-tokens-from-localstorage")
+  (let [id-token (js/localStorage.getItem "id-token")
+        access-token (js/localStorage.getItem "access-token")
+        refresh-token (js/localStorage.getItem "refresh-token")]
+    (when refresh-token
+      (set-tokens! id-token access-token refresh-token)
+      (when-not (or (nil? id-token) (nil? access-token)
+                    (-> id-token parse-jwt almost-expired?)
+                    (-> access-token parse-jwt almost-expired?))
+        (go
+          ;; id-token or access-token expired
+          (<! (<refresh-id-token&access-token))
+          ;; refresh remote graph list by pub login event
+          (when (user-uuid) (state/pub-event! [:user/login])))))))
+
+(defn login-callback [code]
+  (state/set-state! [:ui/loading? :login] true)
+  (go
+    (let [resp (<! (http/get (str "https://" config/API-DOMAIN "/auth_callback?code=" code)
+                             {:with-credentials? false}))]
+      (if (= 200 (:status resp))
+        (-> resp
+            :body
+            (as-> $ (set-tokens! (:id_token $) (:access_token $) (:refresh_token $)))
+            (#(state/pub-event! [:user/login])))
+        (debug/pprint "login-callback" resp)))))
+
+(defn logout []
+  (clear-tokens)
+  (state/pub-event! [:user/logout]))
+
+
+
 ;;; refresh tokens loop
 (def stop-refresh false)
 (defn refresh-tokens-loop []
@@ -124,6 +167,16 @@
         (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))))
+          (<! (<refresh-id-token&access-token)))))
     (when-not stop-refresh
       (recur))))
+
+(defn alpha-user?
+  []
+  (or config/dev?
+      (contains? (state/user-groups) "alpha-tester")))
+
+(comment
+  (defn beta-user?
+   []
+   (contains? (state/user-groups) "beta-tester")))

+ 103 - 66
src/main/frontend/handler/web/nfs.cljs

@@ -119,7 +119,8 @@
 
 ;; TODO: extract code for `ls-dir-files` and `reload-dir!`
 (defn ls-dir-files-with-handler!
-  [ok-handler]
+  ([ok-handler] (ls-dir-files-with-handler! ok-handler nil))
+  ([ok-handler {:keys [empty-dir?-or-pred dir-result-fn]}]
   (let [path-handles (atom {})
         electron? (util/electron?)
         mobile-native? (mobile-util/native-platform?)
@@ -128,72 +129,108 @@
         *repo (atom nil)]
     ;; TODO: add ext filter to avoid loading .git or other ignored file handlers
     (->
-     (p/let [result (fs/open-dir (fn [path handle]
-                                   (when nfs?
-                                     (swap! path-handles assoc path handle))))
-             root-handle (first result)
-             dir-name (if nfs?
-                        (gobj/get root-handle "name")
-                        root-handle)
-             repo (str config/local-db-prefix dir-name)
-             _ (state/set-loading-files! repo true)
-             _ (when-not (or (state/home?) (state/setups-picker?))
-                 (route-handler/redirect-to-home! false))]
-       (reset! *repo repo)
-       (when-not (string/blank? dir-name)
-         (p/let [root-handle-path (str config/local-handle-prefix dir-name)
-                 _ (when nfs?
-                     (idb/set-item! root-handle-path root-handle)
-                     (nfs/add-nfs-file-handle! root-handle-path root-handle))
-                 result (nth result 1)
-                 files (-> (->db-files mobile-native? electron? dir-name result)
-                           (remove-ignore-files dir-name nfs?))
-                 _ (when nfs?
-                     (let [file-paths (set (map :file/path files))]
-                       (swap! path-handles (fn [handles]
-                                             (->> handles
-                                                  (filter (fn [[path _handle]]
-                                                            (or
-                                                             (contains? file-paths
-                                                                        (string/replace-first path (str dir-name "/") ""))
-                                                             (let [last-part (last (string/split path "/"))]
-                                                               (contains? #{config/app-name
-                                                                            gp-config/default-draw-directory
-                                                                            (config/get-journals-directory)
-                                                                            (config/get-pages-directory)}
-                                                                          last-part)))))
-                                                  (into {})))))
+      (p/let [result (if (fn? dir-result-fn)
+                       (dir-result-fn {:path-handles path-handles :nfs? nfs?})
+                       (fs/open-dir (fn [path handle]
+                                      (when nfs?
+                                        (swap! path-handles assoc path handle)))))
+              _ (when-not (nil? empty-dir?-or-pred)
+                  (cond
+                    (boolean? empty-dir?-or-pred)
+                    (and (not-empty (second result))
+                         (throw (js/Error. "EmptyDirOnly")))
 
-                     (set-files! @path-handles))
-                 markup-files (filter-markup-and-built-in-files files)]
-           (-> (p/all (map (fn [file]
-                             (p/let [content (if nfs?
-                                               (.text (:file/file file))
-                                               (:file/content file))
-                                     content (encrypt/decrypt content)]
-                               (assoc file :file/content content))) markup-files))
-               (p/then (fn [result]
-                         (let [files (map #(dissoc % :file/file) result)]
-                           (repo-handler/start-repo-db-if-not-exists! repo)
-                           (async/go
-                             (let [_finished? (async/<! (repo-handler/load-repo-to-db! repo
-                                                                                       {:new-graph?   true
-                                                                                        :empty-graph? (nil? (seq markup-files))
-                                                                                        :nfs-files    files}))]
-                               (state/add-repo! {:url repo :nfs? true})
-                               (state/set-loading-files! repo false)
-                               (when ok-handler (ok-handler))
-                               (fs/watch-dir! dir-name)
-                               (db/persist-if-idle! repo))))))
-               (p/catch (fn [error]
-                          (log/error :nfs/load-files-error repo)
-                          (log/error :exception error)))))))
-     (p/catch (fn [error]
-                (log/error :exception error)
-                (when (contains? #{"AbortError" "Error"} (gobj/get error "name"))
-                  (when @*repo (state/set-loading-files! @*repo false))
-                  ;; (log/error :nfs/open-dir-error error)
-                  ))))))
+                    (fn? empty-dir?-or-pred)
+                    (empty-dir?-or-pred result)))
+              root-handle (first result)
+              dir-name (if nfs?
+                         (gobj/get root-handle "name")
+                         root-handle)
+              repo (str config/local-db-prefix dir-name)
+              _ (state/set-loading-files! repo true)
+              _ (when-not (or (state/home?) (state/setups-picker?))
+                  (route-handler/redirect-to-home! false))]
+             (reset! *repo repo)
+             (when-not (string/blank? dir-name)
+               (p/let [root-handle-path (str config/local-handle-prefix dir-name)
+                       _ (when nfs?
+                           (idb/set-item! root-handle-path root-handle)
+                           (nfs/add-nfs-file-handle! root-handle-path root-handle))
+                       result (nth result 1)
+                       files (-> (->db-files mobile-native? electron? dir-name result)
+                                 (remove-ignore-files dir-name nfs?))
+                       _ (when nfs?
+                           (let [file-paths (set (map :file/path files))]
+                             (swap! path-handles (fn [handles]
+                                                   (->> handles
+                                                        (filter (fn [[path _handle]]
+                                                                  (or
+                                                                    (contains? file-paths
+                                                                               (string/replace-first path (str dir-name "/") ""))
+                                                                    (let [last-part (last (string/split path "/"))]
+                                                                      (contains? #{config/app-name
+                                                                                   gp-config/default-draw-directory
+                                                                                   (config/get-journals-directory)
+                                                                                   (config/get-pages-directory)}
+                                                                                 last-part)))))
+                                                        (into {})))))
+
+                           (set-files! @path-handles))
+                       markup-files (filter-markup-and-built-in-files files)]
+                      (-> (p/all (map (fn [file]
+                                        (p/let [content (if nfs?
+                                                          (.text (:file/file file))
+                                                          (:file/content file))
+                                                content (encrypt/decrypt content)]
+                                               (assoc file :file/content content))) markup-files))
+                          (p/then (fn [result]
+                                    (p/let [files (map #(dissoc % :file/file) result)
+                                            graph-txid-meta (util-fs/read-graph-txid-info dir-name)
+                                            graph-uuid (and (vector? graph-txid-meta) (second graph-txid-meta))]
+                                      (if-let [exists-graph (state/get-sync-graph-by-uuid graph-uuid)]
+                                        (state/pub-event!
+                                         [:notification/show
+                                          {:content (str "This graph already exists in \"" (:root exists-graph) "\"")
+                                           :status :warning}])
+                                        (do
+                                          (repo-handler/start-repo-db-if-not-exists! repo)
+                                          (async/go
+                                            (let [_finished? (async/<! (repo-handler/load-repo-to-db! repo
+                                                                                                      {:new-graph?   true
+                                                                                                       :empty-graph? (nil? (seq markup-files))
+                                                                                                       :nfs-files    files}))]
+                                              (state/add-repo! {:url repo :nfs? true})
+                                              (state/set-loading-files! repo false)
+                                              (when ok-handler (ok-handler {:url repo}))
+                                              (fs/watch-dir! dir-name)
+                                              (db/persist-if-idle! repo))))))))
+                          (p/catch (fn [error]
+                                     (log/error :nfs/load-files-error repo)
+                                     (log/error :exception error)))))))
+      (p/catch (fn [error]
+                 (log/error :exception error)
+                 (when mobile-native?
+                   (state/pub-event!
+                    [:notification/show {:content (str error) :status :error}]))
+                 (when (contains? #{"AbortError" "Error"} (gobj/get error "name"))
+                   (when @*repo (state/set-loading-files! @*repo false))
+                   (throw error)
+                   )))))))
+
+(defn ls-dir-files-with-path!
+  ([path] (ls-dir-files-with-path! path nil))
+  ([path opts]
+   (when-let [dir-result-fn
+              (and path (fn [{:keys [path-handles nfs?]}]
+                          (p/let [files-result (fs/get-files
+                                                path
+                                                (fn [path handle]
+                                                  (when nfs?
+                                                    (swap! path-handles assoc path handle))))]
+                                 [path files-result])))]
+     (ls-dir-files-with-handler!
+      (:ok-handler opts)
+      (merge {:dir-result-fn dir-result-fn} opts)))))
 
 (defn- compute-diffs
   [old-files new-files]

+ 22 - 17
src/main/frontend/mobile/core.cljs

@@ -11,8 +11,9 @@
             [frontend.state :as state]
             [frontend.util :as util]))
 
+
 (def *url (atom nil))
-;; FIXME: `appUrlOpen` are fired twice when receiving a same intent. 
+;; FIXME: `appUrlOpen` are fired twice when receiving a same intent.
 ;; The following two variable atoms are used to compare whether
 ;; they are from the same intent share.
 (def *last-shared-url (atom nil))
@@ -21,20 +22,20 @@
 (defn- ios-init
   "Initialize iOS-specified event listeners"
   []
-  (p/let [path (mobile-fs/iOS-ensure-documents!)]
+  (p/let [path (mobile-fs/ios-ensure-documents!)]
     (println "iOS container path: " (js->clj path)))
 
   (state/pub-event! [:validate-appId])
-  
+
   (.addEventListener js/window
                      "load"
                      (fn [_event]
                        (when @*url
                          (js/setTimeout #(deeplink/deeplink @*url)
                                         1000))))
-  
+
   (mobile-util/check-ios-zoomed-display)
-  
+
   (.removeAllListeners mobile-util/file-sync)
 
   (.addListener mobile-util/file-sync "debug"
@@ -67,6 +68,14 @@
 
   (.addEventListener js/window "sendIntentReceived"
                        #(intent/handle-received)))
+(defn- app-state-change-handler
+  [^js state]
+  (println :debug :app-state-change-handler state (js/Date.))
+  (when (state/get-current-repo)
+    (let [is-active? (.-isActive state)]
+      (state/set-mobile-app-state-change is-active?)
+      (when-not is-active?
+        (editor-handler/save-current-block!)))))
 
 (defn- general-init
   "Initialize event listeners used by both iOS and Android"
@@ -87,23 +96,19 @@
                   (state/pub-event! [:file-watcher/changed event])))
 
   (.addListener Keyboard "keyboardWillShow"
-                  (fn [^js info]
-                    (let [keyboard-height (.-keyboardHeight info)]
-                      (state/pub-event! [:mobile/keyboard-will-show keyboard-height]))))
+                (fn [^js info]
+                  (let [keyboard-height (.-keyboardHeight info)]
+                    (state/pub-event! [:mobile/keyboard-will-show keyboard-height]))))
 
   (.addListener Keyboard "keyboardWillHide"
-                  (fn []
-                    (state/pub-event! [:mobile/keyboard-will-hide])))
+                (fn []
+                  (state/pub-event! [:mobile/keyboard-will-hide])))
 
   (.addEventListener js/window "statusTap"
                      #(util/scroll-to-top true))
 
-  (.addListener App "appStateChange"
-                (fn [^js state]
-                  (when (state/get-current-repo)
-                    (let [is-active? (.-isActive state)]
-                      (when-not is-active?
-                        (editor-handler/save-current-block!)))))))
+  (.addListener App "appStateChange" app-state-change-handler))
+
 
 (defn init! []
   (when (mobile-util/native-android?)
@@ -111,6 +116,6 @@
 
   (when (mobile-util/native-ios?)
     (ios-init))
-  
+
   (when (mobile-util/native-platform?)
     (general-init)))

+ 6 - 5
src/main/frontend/mobile/deeplink.cljs

@@ -1,6 +1,7 @@
 (ns frontend.mobile.deeplink
   (:require
    [clojure.string :as string]
+   [goog :refer [Uri]]
    [frontend.config :as config]
    [frontend.db.model :as db-model]
    [frontend.handler.editor :as editor-handler]
@@ -14,10 +15,10 @@
 (def *link-to-another-graph (atom false))
 
 (defn deeplink [url]
-  (let [parsed-url (js/URL. url)
-        hostname (.-hostname parsed-url)
-        pathname (.-pathname parsed-url)
-        search-params (.-searchParams parsed-url)
+  (let [^js/Uri parsed-url (.parse Uri url)
+        hostname (.getDomain parsed-url)
+        pathname (.getPath parsed-url)
+        search-params (.getQueryData parsed-url)
         current-repo-url (state/get-current-repo)
         get-graph-name-fn #(-> (text-util/get-graph-name-from-path %)
                                (string/split "/")
@@ -30,7 +31,7 @@
         repo-names (map #(get-graph-name-fn %) repos)]
     (cond
       (= hostname "auth-callback")
-      (when-let [code (.get search-params  "code")]
+      (when-let [code (.get search-params "code")]
         (user-handler/login-callback code))
 
       (= hostname "graph")

+ 3 - 0
src/main/frontend/mobile/util.cljs

@@ -27,6 +27,9 @@
   (defonce ios-file-container (registerPlugin "FileContainer"))
   (defonce file-sync (registerPlugin "FileSync")))
 
+(when (native-android?)
+  (defonce file-sync (registerPlugin "FileSync")))
+
 ;; NOTE: both iOS and android share the same FsWatcher API
 (when (native-platform?)
   (defonce fs-watcher (registerPlugin "FsWatcher")))

+ 1 - 1
src/main/frontend/modules/file/core.cljs

@@ -4,9 +4,9 @@
             [frontend.date :as date]
             [frontend.db :as db]
             [frontend.db.utils :as db-utils]
-            [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util.property :as property]
+            [frontend.state :as state]
             [frontend.handler.file :as file-handler]))
 
 (defn- indented-block-content

+ 11 - 10
src/main/frontend/modules/outliner/file.cljs

@@ -12,14 +12,8 @@
             [lambdaisland.glogi :as log]
             [frontend.state :as state]))
 
-(defonce write-chan-batch-buf (atom []))
-
 (def batch-write-interval 1000)
 
-(defn writes-finished?
-  []
-  (empty? @write-chan-batch-buf))
-
 (defn do-write-file!
   [repo page-db-id]
   (let [page-block (db/pull repo '[*] page-db-id)
@@ -62,7 +56,14 @@
         (write-files! [[repo page-db-id]])
         (async/put! (state/get-file-write-chan) [repo page-db-id])))))
 
-(util/batch (state/get-file-write-chan)
-            batch-write-interval
-            write-files!
-            write-chan-batch-buf)
+(def *writes-finished? (atom true))
+
+(defn <ratelimit-file-writes!
+  []
+  (util/<ratelimit (state/get-file-write-chan) batch-write-interval
+                 :filter-fn
+                 #(do (reset! *writes-finished? false) true)
+                 :flush-fn
+                 #(do
+                    (write-files! %)
+                    (reset! *writes-finished? true))))

+ 1 - 1
src/main/frontend/modules/shortcut/before.cljs

@@ -32,6 +32,6 @@
 (defn enable-when-not-component-editing!
   [f]
   (fn [e]
-    (when (or (contains? #{:srs} (state/get-modal-id))
+    (when (or (contains? #{:srs :page-histories} (state/get-modal-id))
               (not (state/block-component-editing?)))
       (f e))))

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

@@ -325,6 +325,8 @@
    :go/graph-view                  {:binding "g g"
                                     :fn      route-handler/redirect-to-graph-view!}
 
+   :go/all-graphs                  {:binding "g shift+g"
+                                    :fn      route-handler/redirect-to-all-graphs}
 
    :go/keyboard-shortcuts          {:binding "g s"
                                     :fn      #(route-handler/redirect! {:to :shortcut-setting})}
@@ -534,6 +536,7 @@
                           :go/all-pages
                           :go/flashcards
                           :go/graph-view
+                          :go/all-graphs
                           :go/keyboard-shortcuts
                           :go/tomorrow
                           :go/next-journal
@@ -599,6 +602,7 @@
     :go/journals
     :go/all-pages
     :go/graph-view
+    :go/all-graphs
     :go/flashcards
     :go/tomorrow
     :go/next-journal

+ 1 - 0
src/main/frontend/modules/shortcut/dicts.cljc

@@ -96,6 +96,7 @@
    :graph/re-index                 "Re-index current graph"
    :command/run                    "Run git command"
    :go/home                        "Go to home"
+   :go/all-graphs                  "Go to all graphs"
    :go/all-pages                   "Go to all pages"
    :go/graph-view                  "Go to graph view"
    :go/keyboard-shortcuts          "Go to keyboard shortcuts"

+ 2 - 1
src/main/frontend/spec/storage.cljc

@@ -56,4 +56,5 @@
             :document/mode?
             :ui/shortcut-tooltip?
             :copy/export-block-text-indent-style
-            :copy/export-block-text-remove-options]))
+            :copy/export-block-text-remove-options
+            :file-sync/onboarding-state]))

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است