Forráskód Böngészése

Merge branch 'master' into enhance/reading-mode

charlie 3 éve
szülő
commit
1f5acfcc93
100 módosított fájl, 3114 hozzáadás és 1263 törlés
  1. 3 1
      .carve/ignore
  2. 5 1
      .clj-kondo/config.edn
  3. 22 1
      .github/workflows/build-android.yml
  4. 19 1
      .github/workflows/build-desktop-release.yml
  5. 12 1
      .github/workflows/build.yml
  6. 4 1
      .gitignore
  7. 3 2
      android/app/build.gradle
  8. 2 0
      android/app/capacitor.build.gradle
  9. 9 0
      android/app/src/main/AndroidManifest.xml
  10. 8 0
      android/app/src/main/assets/capacitor.plugins.json
  11. 218 0
      android/app/src/main/java/com/logseq/app/FsWatcher.java
  12. 59 0
      android/app/src/main/java/com/logseq/app/GraphFileSync.java
  13. 21 0
      android/app/src/main/java/com/logseq/app/MainActivity.java
  14. 6 0
      android/capacitor.settings.gradle
  15. 1 0
      android/file-sync/.gitignore
  16. 36 0
      android/file-sync/build.gradle
  17. 0 0
      android/file-sync/consumer-rules.pro
  18. 21 0
      android/file-sync/proguard-rules.pro
  19. 25 0
      android/file-sync/src/androidTest/java/com/logseq/file_sync/ExampleInstrumentedTest.java
  20. 5 0
      android/file-sync/src/main/AndroidManifest.xml
  21. 14 0
      android/file-sync/src/main/java/com/logseq/file_sync/FileSync.java
  22. BIN
      android/file-sync/src/main/jniLibs/arm64-v8a/libfilesync.so
  23. BIN
      android/file-sync/src/main/jniLibs/armeabi-v7a/libfilesync.so
  24. BIN
      android/file-sync/src/main/jniLibs/x86/libfilesync.so
  25. BIN
      android/file-sync/src/main/jniLibs/x86_64/libfilesync.so
  26. 17 0
      android/file-sync/src/test/java/com/logseq/file_sync/ExampleUnitTest.java
  27. 2 1
      android/settings.gradle
  28. 9 1
      bb.edn
  29. 3 2
      deps.edn
  30. 7 6
      docs/contributing-to-translations.md
  31. 21 0
      docs/dev-practices.md
  32. 1 1
      docs/develop-logseq.md
  33. 1 1
      docs/docker-web-app-guide.md
  34. 112 122
      e2e-tests/basic.spec.ts
  35. 3 3
      e2e-tests/code-editing.spec.ts
  36. 82 0
      e2e-tests/dnd.spec.ts
  37. 127 21
      e2e-tests/editor.spec.ts
  38. 22 11
      e2e-tests/fixtures.ts
  39. 18 18
      e2e-tests/hotkey.spec.ts
  40. 5 3
      e2e-tests/page-rename.spec.ts
  41. 43 32
      e2e-tests/page-search.spec.ts
  42. 4 3
      e2e-tests/sidebar.spec.ts
  43. 64 0
      e2e-tests/util/keyboard-event-cap.html
  44. 466 0
      e2e-tests/util/keyboard-events.ts
  45. 61 32
      e2e-tests/utils.ts
  46. 13 6
      gulpfile.js
  47. 179 7
      ios/App/App.xcodeproj/project.pbxproj
  48. 4 0
      ios/App/App/App.entitlements
  49. 4 0
      ios/App/App/AppDebug.entitlements
  50. 39 7
      ios/App/App/AppDelegate.swift
  51. 0 1
      ios/App/App/DownloadiCloudFiles.m
  52. 0 13
      ios/App/App/DownloadiCloudFiles.swift
  53. 17 2
      ios/App/App/FsWatcher.swift
  54. 13 0
      ios/App/App/Info.plist
  55. 1 0
      ios/App/Podfile
  56. 24 0
      ios/App/ShareViewController/Base.lproj/MainInterface.storyboard
  57. 33 0
      ios/App/ShareViewController/Info.plist
  58. 10 0
      ios/App/ShareViewController/ShareViewController.entitlements
  59. 201 0
      ios/App/ShareViewController/ShareViewController.swift
  60. 9 0
      libs/src/LSPlugin.core.ts
  61. 4 4
      libs/yarn.lock
  62. 7 7
      package.json
  63. 9 3
      resources/css/common.css
  64. 0 3
      resources/css/tabler-icons.min.css
  65. BIN
      resources/fonts/tabler-icons.eot
  66. BIN
      resources/fonts/tabler-icons.woff
  67. BIN
      resources/fonts/tabler-icons.woff2
  68. 7 0
      resources/forge.config.js
  69. BIN
      resources/img/file-edn.png
  70. BIN
      resources/img/folder-logo.png
  71. BIN
      resources/img/folder.png
  72. 0 0
      resources/js/isomorphic-git/1.7.4/index.umd.min.js
  73. 0 0
      resources/js/magic_portal.js
  74. 6 2
      resources/package.json
  75. 52 0
      scripts/lint_rules.clj
  76. 1 1
      scripts/src/logseq/tasks/dev.clj
  77. 11 41
      scripts/src/logseq/tasks/lang.clj
  78. 0 27
      scripts/src/logseq/tasks/rewrite_clj.clj
  79. 19 0
      scripts/src/logseq/tasks/spec.clj
  80. 5 3
      shadow-cljs.edn
  81. 56 34
      src/electron/electron/core.cljs
  82. 26 0
      src/electron/electron/file_sync_rsapi.cljs
  83. 43 20
      src/electron/electron/fs_watcher.cljs
  84. 1 1
      src/electron/electron/git.cljs
  85. 153 64
      src/electron/electron/handler.cljs
  86. 5 0
      src/electron/electron/state.cljs
  87. 12 4
      src/electron/electron/window.cljs
  88. 36 29
      src/main/electron/listener.cljs
  89. 2 2
      src/main/frontend/commands.cljs
  90. 310 327
      src/main/frontend/components/block.cljs
  91. 2 2
      src/main/frontend/components/block.css
  92. 7 4
      src/main/frontend/components/content.cljs
  93. 4 2
      src/main/frontend/components/content.css
  94. 23 29
      src/main/frontend/components/editor.cljs
  95. 1 0
      src/main/frontend/components/editor.css
  96. 130 80
      src/main/frontend/components/header.cljs
  97. 31 13
      src/main/frontend/components/header.css
  98. 38 60
      src/main/frontend/components/journal.cljs
  99. 5 165
      src/main/frontend/components/onboarding.cljs
  100. 0 35
      src/main/frontend/components/onboarding.css

+ 3 - 1
.carve/ignore

@@ -58,6 +58,8 @@ frontend.ui/reset-ios-whole-page-offset!
 frontend.util/d
 ;; Future use?
 frontend.util/safe-search-normalize
+frontend.components.external/import-cp
+frontend.components.repo/add-repo
 ;; For debugging
 frontend.util/trace!
 ;; Repl fn
@@ -65,4 +67,4 @@ frontend.util.pool/terminate-pool!
 ;; Repl fn
 frontend.util.property/add-page-properties
 ;; Used by shadow
-frontend.node-test-runner/main
+frontend.test.node-test-runner/main

+ 5 - 1
.clj-kondo/config.edn

@@ -13,7 +13,11 @@
   {:aliases {datascript.core d
              datascript.transit dt
              datascript.db ddb
-             lambdaisland.glogi log}}}
+             lambdaisland.glogi log
+             medley.core medley
+             frontend.db.query-dsl query-dsl
+             frontend.db.react react
+             frontend.db.query-react query-react}}}
 
  :hooks {:analyze-call {rum.core/defc hooks.rum/defc
                          rum.core/defcs hooks.rum/defcs}}

+ 22 - 1
.github/workflows/build-android.yml

@@ -7,10 +7,14 @@ on:
   workflow_dispatch:
     inputs:
       build-target:
-        description: 'Build Target ("nightly"/"beta")'
+        description: 'Build Target ("nightly"/"beta"/"non-release")'
         type: string
         required: true
         default: "beta"
+      git-ref:
+        description: "Build from Git Ref(master)"
+        required: true
+        default: "master"
   workflow_call:
     inputs:
       build-target:
@@ -21,6 +25,8 @@ on:
         required: true
       ANDROID_KEYSTORE_PASSWORD:
         required: true
+      SENTRY_AUTH_TOKEN:
+        required: true
 
 env:
   CLOJURE_VERSION: '1.10.1.763'
@@ -32,6 +38,8 @@ jobs:
     steps:
       - name: Check out Git repository
         uses: actions/checkout@v2
+        with:
+          ref: ${{ github.event.inputs.git-ref }}
 
       - name: Install Node.js, NPM and Yarn
         uses: actions/setup-node@v2
@@ -84,6 +92,19 @@ jobs:
       - name: Compile CLJS
         run: yarn install && yarn release
 
+      - name: Upload Sentry Sourcemaps (beta only)
+        if: ${{ inputs.build-target == 'beta' || github.event.inputs.build-target == 'beta' }}
+        run: |
+          curl -sL https://sentry.io/get-cli/ | bash
+          release_name="logseq-android@${{ steps.ref.outputs.version }}"
+          sentry-cli releases new "${release_name}"
+          sentry-cli releases files "${release_name}" upload-sourcemaps --ext map --ext js ./static/js --url-prefix '~/static/js'
+          sentry-cli releases finalize "${release_name}"
+        env:
+          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+          SENTRY_ORG: logseq
+          SENTRY_PROJECT: logseq
+
       - name: Prepare public Directory
         run: |
           cp -r static public/

+ 19 - 1
.github/workflows/build-desktop-release.yml

@@ -107,6 +107,19 @@ jobs:
         run: ls -al
         working-directory: ./static
 
+      - name: Upload Sentry Sourcemaps (beta only)
+        if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build-target == 'beta' }}
+        run: |
+          curl -sL https://sentry.io/get-cli/ | bash
+          release_name="logseq@${{ steps.ref.outputs.version }}"
+          sentry-cli releases new "${release_name}"
+          sentry-cli releases files "${release_name}" upload-sourcemaps --ext map --ext js ./static/js --url-prefix '~/static/js'
+          sentry-cli releases finalize "${release_name}"
+        env:
+          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+          SENTRY_ORG: logseq
+          SENTRY_PROJECT: logseq
+
       - name: Cache Static File
         uses: actions/upload-artifact@v2
         with:
@@ -330,8 +343,12 @@ jobs:
       #       **/node_modules
       #     key: ${{ runner.os }}-node-modules
 
+      - name: Fetch deps and fix dugit arch for arm64
+        run: yarn install --ignore-platform && cd node_modules/dugite && npm_config_arch=arm64 node script/download-git.js
+        working-directory: ./static
+
       - name: Build/Release Electron App for arm64
-        run: yarn install && yarn electron:make-macos-arm64
+        run: yarn electron:make-macos-arm64
         working-directory: ./static
         env:
           APPLE_ID: ${{ secrets.APPLE_ID_EMAIL }}
@@ -358,6 +375,7 @@ jobs:
     secrets:
       ANDROID_KEYSTORE: "${{ secrets.ANDROID_KEYSTORE }}"
       ANDROID_KEYSTORE_PASSWORD: "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"
+      SENTRY_AUTH_TOKEN: "${{ secrets.SENTRY_AUTH_TOKEN }}"
 
   nightly-release:
     if: ${{ github.event_name == 'schedule' || github.event.inputs.build-target == 'nightly' }}

+ 12 - 1
.github/workflows/build.yml

@@ -17,7 +17,7 @@ env:
   JAVA_VERSION: '8'
   # This is the latest node version we can run.
   NODE_VERSION: '16'
-  BABASHKA_VERSION: '0.7.3'
+  BABASHKA_VERSION: '0.7.7'
 
 jobs:
 
@@ -113,6 +113,9 @@ jobs:
       - name: Lint invalid translation entries
         run: bb lang:invalid-translations
 
+      - name: Lint datalog rules
+        run: scripts/lint_rules.clj
+
   e2e-test:
     runs-on: ubuntu-latest
 
@@ -174,6 +177,14 @@ jobs:
           yarn gulp:build && yarn cljs:release
           (cd static && yarn install && yarn rebuild:better-sqlite3)
 
+      # Exits with 0 if yarn.lock is up to date or 1 if we forgot to update it
+      - name: Ensure static yarn.lock is up to date
+        run: git diff --exit-code static/yarn.lock
+
+      # If not building, the `event_` of `goog.event.KeyboardEvent` would be missing
+      - name: Build app
+        run: clojure -M:cljs compile app
+
       - name: Run Playwright test
         run: xvfb-run -- yarn e2e-test
         env:

+ 4 - 1
.gitignore

@@ -31,6 +31,7 @@ strings.csv
 .calva
 resources/electron.js
 .clj-kondo/.cache
+.clj-kondo/babashka/sci
 .lsp/
 /libs/dist/
 charlie/
@@ -38,4 +39,6 @@ charlie/
 /.preprocessor-cljs
 docker
 android/app/src/main/assets/capacitor.plugin.json
-ios/App/App/capacitor.config.json
+ios/App/App/capacitor.config.json
+
+startup.png

+ 3 - 2
android/app/build.gradle

@@ -6,8 +6,8 @@ android {
         applicationId "com.logseq.app"
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 14
-        versionName "0.6.1"
+        versionCode 18
+        versionName "0.6.5"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         aaptOptions {
              // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -38,6 +38,7 @@ dependencies {
     androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
     androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
     implementation project(':capacitor-cordova-android-plugins')
+    implementation project(':file-sync')
 }
 
 apply from: 'capacitor.build.gradle'

+ 2 - 0
android/app/capacitor.build.gradle

@@ -14,6 +14,8 @@ dependencies {
     implementation project(':capacitor-filesystem')
     implementation project(':capacitor-keyboard')
     implementation project(':capacitor-splash-screen')
+    implementation project(':capacitor-status-bar')
+    implementation project(':send-intent')
 
 }
 

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

@@ -23,6 +23,15 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
 
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="text/plain" />
+                <data android:mimeType="image/*" />
+                <data android:mimeType="application/*" />
+                <data android:mimeType="video/*" />
+            </intent-filter>
+
         </activity>
 
         <provider

+ 8 - 0
android/app/src/main/assets/capacitor.plugins.json

@@ -18,5 +18,13 @@
 	{
 		"pkg": "@capacitor/splash-screen",
 		"classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin"
+	},
+	{
+		"pkg": "@capacitor/status-bar",
+		"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
+	},
+	{
+		"pkg": "send-intent",
+		"classpath": "de.mindlib.sendIntent.SendIntent"
 	}
 ]

+ 218 - 0
android/app/src/main/java/com/logseq/app/FsWatcher.java

@@ -0,0 +1,218 @@
+package com.logseq.app;
+
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.StructStat;
+import android.util.Log;
+import android.os.FileObserver;
+import android.net.Uri;
+
+import java.io.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import java.io.File;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.PluginCall;
+
+@CapacitorPlugin(name = "FsWatcher")
+public class FsWatcher extends Plugin {
+
+    List<SingleFileObserver> observers;
+    private String mPath;
+    private Uri mPathUri;
+
+    @Override
+    public void load() {
+        Log.i("FsWatcher", "Android fs-watcher loaded!");
+    }
+
+    @PluginMethod()
+    public void watch(PluginCall call) {
+        String pathParam = call.getString("path");
+        // check file:// or no scheme uris
+        Uri u = Uri.parse(pathParam);
+        Log.i("FsWatcher", "watching " + u);
+        if (u.getScheme() == null || u.getScheme().equals("file")) {
+            File pathObj = new File(u.getPath());
+            if (pathObj == null) {
+                call.reject("invalid watch path: " + pathParam);
+                return;
+            }
+            mPathUri = Uri.fromFile(pathObj);
+            mPath = pathObj.getAbsolutePath();
+
+            int mask = FileObserver.CLOSE_WRITE |
+                    FileObserver.MOVE_SELF | FileObserver.MOVED_FROM | FileObserver.MOVED_TO |
+                    FileObserver.DELETE | FileObserver.DELETE_SELF;
+
+            if (observers != null) {
+                call.reject("already watching");
+                return;
+            }
+            observers = new ArrayList<SingleFileObserver>();
+            observers.add(new SingleFileObserver(pathObj, mask));
+
+            // NOTE: only watch first level of directory
+            File[] files = pathObj.listFiles();
+            if (files != null) {
+                for (int i = 0; i < files.length; ++i) {
+                    if (files[i].isDirectory() && !files[i].getName().startsWith(".")) {
+                        observers.add(new SingleFileObserver(files[i], mask));
+                    }
+                }
+            }
+
+            this.initialNotify(pathObj);
+
+            for (int i = 0; i < observers.size(); i++)
+                observers.get(i).startWatching();
+            call.resolve();
+        } else {
+            call.reject(u.getScheme() + " scheme not supported");
+        }
+    }
+
+    @PluginMethod()
+    public void unwatch(PluginCall call) {
+        Log.i("FsWatcher", "unwatching...");
+
+        if (observers != null) {
+            for (int i = 0; i < observers.size(); ++i)
+                observers.get(i).stopWatching();
+            observers.clear();
+            observers = null;
+        }
+
+        call.resolve();
+    }
+
+    public void initialNotify(File pathObj) {
+        this.initialNotify(pathObj, 2);
+    }
+
+    public void initialNotify(File pathObj, int maxDepth) {
+        if (maxDepth == 0) {
+            return;
+        }
+        File[] files = pathObj.listFiles();
+        if (files != null) {
+            for (int i = 0; i < files.length; ++i) {
+                if (files[i].isDirectory() && !files[i].getName().startsWith(".") && !files[i].getName().equals("bak")) {
+                    this.initialNotify(files[i], maxDepth - 1);
+                } else if (files[i].isFile()
+                        && Pattern.matches("[^.].*?\\.(md|org|css|edn|text|markdown|yml|yaml|json|js)$",
+                                files[i].getName())) {
+                    this.onObserverEvent(FileObserver.CREATE, files[i].getAbsolutePath());
+                }
+            }
+        }
+    }
+
+    // add, change, unlink events
+    public void onObserverEvent(int event, String path) {
+        JSObject obj = new JSObject();
+        String content = null;
+        // FIXME: Current repo/path impl requires path to be a URL, dir to be a bare
+        // path.
+        File f = new File(path);
+        obj.put("path", Uri.fromFile(f));
+        obj.put("dir", mPath);
+        Log.i("FsWatcher", "prepare event " + obj);
+
+        switch (event) {
+            case FileObserver.CLOSE_WRITE:
+                obj.put("event", "change");
+                try {
+                    obj.put("stat", getFileStat(path));
+                    content = getFileContents(f);
+                } catch (IOException e) {
+                    e.printStackTrace();
+                } catch (ErrnoException e) {
+                    e.printStackTrace();
+                }
+                obj.put("content", content);
+                break;
+            case FileObserver.MOVED_TO:
+            case FileObserver.CREATE:
+                obj.put("event", "add");
+                try {
+                    obj.put("stat", getFileStat(path));
+                    content = getFileContents(f);
+                } catch (IOException e) {
+                    e.printStackTrace();
+                } catch (ErrnoException e) {
+                    e.printStackTrace();
+                }
+                obj.put("content", content);
+                break;
+            case FileObserver.MOVE_SELF:
+            case FileObserver.MOVED_FROM:
+            case FileObserver.DELETE:
+            case FileObserver.DELETE_SELF:
+                obj.put("event", "unlink");
+                break;
+            default:
+                // unreachable?
+                obj.put("event", "unknown");
+                break;
+        }
+
+        notifyListeners("watcher", obj);
+    }
+
+    public static String getFileContents(final File file) throws IOException {
+        InputStream inputStream = new FileInputStream(file);
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+        byte[] buffer = new byte[1024];
+        int length = 0;
+
+        while ((length = inputStream.read(buffer)) != -1) {
+            outputStream.write(buffer, 0, length);
+        }
+
+        inputStream.close();
+        return outputStream.toString("utf-8");
+    }
+
+    public static JSObject getFileStat(final String path) throws ErrnoException {
+        StructStat stat = Os.stat(path);
+        JSObject obj = new JSObject();
+        obj.put("atime", stat.st_atime);
+        obj.put("mtime", stat.st_mtime);
+        obj.put("ctime", stat.st_ctime);
+        return obj;
+    }
+
+    private class SingleFileObserver extends FileObserver {
+        private String mPath;
+
+        public SingleFileObserver(String path, int mask) {
+            super(path, mask);
+            mPath = path;
+        }
+
+        public SingleFileObserver(File path, int mask) {
+            super(path, mask);
+            mPath = path.getAbsolutePath();
+        }
+
+        @Override
+        public void onEvent(int event, String path) {
+            if (path != null) {
+                Log.d("FsWatcher", "got path=" + path + " event=" + event);
+                if (Pattern.matches("[^.].*?\\.(md|org|css|edn|text|markdown|yml|yaml|json|js)$", path)) {
+                    String fullPath = mPath + "/" + path;
+                    FsWatcher.this.onObserverEvent(event, fullPath);
+                }
+            }
+        }
+    }
+}

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

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

+ 21 - 0
android/app/src/main/java/com/logseq/app/MainActivity.java

@@ -1,6 +1,8 @@
 package com.logseq.app;
 
+import android.content.Intent;
 import android.os.Bundle;
+import android.webkit.ValueCallback;
 
 import com.getcapacitor.BridgeActivity;
 
@@ -9,6 +11,7 @@ public class MainActivity extends BridgeActivity {
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         registerPlugin(FolderPicker.class);
+        registerPlugin(FsWatcher.class);
     }
 
     @Override
@@ -16,4 +19,22 @@ public class MainActivity extends BridgeActivity {
         overridePendingTransition(0, R.anim.byebye);
         super.onPause();
     }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        String action = intent.getAction();
+        String type = intent.getType();
+        if (Intent.ACTION_SEND.equals(action) && type != null) {
+            bridge.getActivity().setIntent(intent);
+            bridge.eval("window.dispatchEvent(new Event('sendIntentReceived'))", new ValueCallback<String>() {
+                @Override
+                public void onReceiveValue(String s) {
+                    //
+                }
+            });
+        }
+    }
+
+
 }

+ 6 - 0
android/capacitor.settings.gradle

@@ -16,3 +16,9 @@ project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor
 
 include ':capacitor-splash-screen'
 project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
+
+include ':capacitor-status-bar'
+project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
+
+include ':send-intent'
+project(':send-intent').projectDir = new File('../node_modules/send-intent/android')

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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


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


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


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

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

+ 2 - 1
android/settings.gradle

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

+ 9 - 1
bb.edn

@@ -1,4 +1,9 @@
-{:paths ["scripts/src"]
+{:paths ["scripts/src" "src/main"]
+ :deps
+ {org.babashka/spec.alpha
+  {:git/url "https://github.com/babashka/spec.alpha"
+   :sha "1a841c4cc1d4f6dab7505a98ed2d532dd9d56b78"}
+  medley/medley {:mvn/version "1.3.0"}}
  :tasks
  {dev:watch
   logseq.tasks.dev/watch
@@ -14,6 +19,9 @@
    ;; Parallel execution - https://book.babashka.org/#parallel
    :task (run '-dev:electron-start {:parallel true})}
 
+  dev:validate-local-storage
+  logseq.tasks.spec/validate-local-storage
+
   lang:list
   logseq.tasks.lang/list-langs
 

+ 3 - 2
deps.edn

@@ -2,7 +2,7 @@
  :deps
  {org.clojure/clojure                   {:mvn/version "1.10.0"}
   cheshire/cheshire                     {:mvn/version "5.10.0"}
-  rum/rum                               {:mvn/version "0.12.3"}
+  rum/rum                               {:mvn/version "0.12.9"}
   datascript/datascript                 {:mvn/version "1.3.8"}
   datascript-transit/datascript-transit {:mvn/version "0.3.0"}
   borkdude/rewrite-edn                  {:git/url "https://github.com/borkdude/rewrite-edn"
@@ -17,7 +17,7 @@
                                          :sha     "5704fbf48d3478eedcf24d458c8964b3c2fd59a9"}
   cljs-drag-n-drop/cljs-drag-n-drop     {:mvn/version "0.1.0"}
   cljs-http/cljs-http                   {:mvn/version "0.1.46"}
-  borkdude/sci                          {:mvn/version "0.1.1-alpha.6"}
+  org.babashka/sci                      {:mvn/version "0.3.2"}
   hickory/hickory                       {:git/url "https://github.com/logseq/hickory"
                                          :sha     "9c2c2f1fc2c45efaad906e0faabc3201278deeaa"}
   hiccups/hiccups                       {:mvn/version "0.3.0"}
@@ -43,6 +43,7 @@
            :test {:extra-paths ["src/test/"]
                   :extra-deps  {org.clojure/clojurescript        {:mvn/version "1.10.891"}
                                 org.clojure/test.check           {:mvn/version "1.1.1"}
+                                pjstadig/humane-test-output      {:mvn/version "0.11.0"}
                                 org.clojars.knubie/cljs-run-test {:mvn/version "1.0.1"}}
                   :main-opts   ["-m" "shadow.cljs.devtools.cli"]}
 

+ 7 - 6
docs/contributing-to-translations.md

@@ -14,12 +14,9 @@ In order to run the commands in this doc, you will need to install
 ## Where to Contribute
 
 Language translations are in two files,
-[frontend/dicts.cljs](https://github.com/logseq/logseq/blob/feature/lang-tasks-and-ci/src/main/frontend/dicts.cljs)
+[frontend/dicts.cljs](https://github.com/logseq/logseq/blob/master/src/main/frontend/dicts.cljs)
 and
-[shortcut/dict.cljs](https://github.com/logseq/logseq/blob/feature/lang-tasks-and-ci/src/main/frontend/modules/shortcut/dict.cljs).
-When translating `shortcut/dict.cljs` you will want to refer to
-https://github.com/logseq/logseq/blob/feature/lang-tasks-and-ci/src/main/frontend/modules/shortcut/config.cljs
-for the English equivalent.
+[shortcut/dict.cljs](https://github.com/logseq/logseq/blob/master/src/main/frontend/modules/shortcut/dicts.cljs).
 
 ## Language Overview
 
@@ -68,6 +65,10 @@ $ bb lang:missing
 Now, add keys for your language, save and rerun the above command. Over time
 you're hoping to have this list drop to zero.
 
+Almost all translations are pretty quick. The only exceptions to this are the keys `:tutorial/text` and `:tutorial/dummy-notes`. These reference files that are part of the onboarding tutorial. Most languages don't have this translated. If you are willing to do this, we would be happy to have this translated.
+
+## Fix Mistakes
+
 There is a lot to translate and sometimes we make mistakes. For example, we may leave a string untranslated. To see what translation keys are still left in English:
 
 ```
@@ -88,4 +89,4 @@ detect this error and helpfully show you what was typoed.
 
 To add a new language, add an entry to `frontend.dicts/languages`. Then add a
 new locale keyword to `frontend.dicts/dicts` and to
-`frontend.modules.shortcut.dict/dict` and start translating as described above.
+`frontend.modules.shortcut.dicts/dicts` and start translating as described above.

+ 21 - 0
docs/dev-practices.md

@@ -51,6 +51,13 @@ scripts/large_vars.clj
 
 To configure the linter, see its `config` var.
 
+### Datalog linting
+
+We use [datascript](https://github.com/tonsky/datascript)'s datalog to power our modeling and querying layer. Since datalog is concise, it is easy to write something invalid. To avoid typos and other preventable mistakes, we lint our queries and rules. Our queries are linted through clj-kondo and [datalog-parser](https://github.com/lambdaforge/datalog-parser). clj-kondo will error if it detects an invalid query. Our rules are linted through a script that also uses the datalog-parser. To run this linter:
+```
+scripts/lint_rules.clj
+```
+
 ## Testing
 
 We have unit and end to end tests.
@@ -89,6 +96,8 @@ For this workflow:
   tests. To run all tests except those tests run `node static/tests.js -e focus`.
 3. Or focus namespaces: Using the regex option `-r`, run tests for `frontend.text-test` with `node static/tests.js -r text`.
 
+Multiple options can be specified to AND selections. For example, to run all `frontend.text-test` tests except for the focused one: `node static/tests.js -r text -e focus`
+
 For help on more options, run `node static/tests.js -h`.
 
 #### Autorun Tests
@@ -107,3 +116,15 @@ be sure to have [enabled custom
 formatters](https://github.com/binaryage/cljs-devtools/blob/master/docs/installation.md#enable-custom-formatters-in-chrome)
 in the desktop app and browser. Without this enabled, most of the log messages
 aren't readable.
+
+## Data validation and generation
+
+We currently use [spec](https://github.com/clojure/spec.alpha) for data
+validation (and generation someday). We may switch to
+[malli](https://github.com/metosin/malli) if we need to datafy our data models
+at some point.
+
+Specs should go under `src/main/frontend/spec/` and be compatible with clojure
+and clojurescript. See `frontend.spec.storage` for an example. By following
+these conventions, specs should also be usable by babashka. This is helpful as it
+allows for third party tools to be written with logseq's data model.

+ 1 - 1
docs/develop-logseq.md

@@ -1,7 +1,7 @@
 # Develop Logseq
 ## Requirements
 
-- [Node.js](https://nodejs.org/en/download/) (See [build.yml](.github/workflows/build.yml) for allowed version)  & [Yarn](https://classic.yarnpkg.com/en/docs/install/)
+- [Node.js](https://nodejs.org/en/download/) (See [build.yml](https://github.com/logseq/logseq/blob/master/.github/workflows/build.yml) for allowed version)  & [Yarn](https://classic.yarnpkg.com/en/docs/install/)
 - [Java & Clojure](https://clojure.org/guides/getting_started). (If you run into `Execution error (FileNotFoundException) at java.io.FileInputStream/open0 (FileInputStream.java:-2). -M:cljs (No such file or directory)`, it means you have a wrong Clojure version installed. Please uninstall it and follow the instructions linked.)
 
 ## Clone project

+ 1 - 1
docs/docker-web-app-guide.md

@@ -45,7 +45,7 @@ mkcert 192.168.11.95 # public IP address or hostname of the remote machine
 
 ### Prepare SSL Nginx conf
 
-```
+```nginx
 # ssl.conf
 server {
     listen  443   ssl;

+ 112 - 122
e2e-tests/basic.spec.ts

@@ -1,9 +1,8 @@
 import { expect } from '@playwright/test'
 import fs from 'fs/promises'
 import path from 'path'
-import { test, graphDir } from './fixtures'
-import { randomString, createRandomPage, newBlock } from './utils'
-
+import { test } from './fixtures'
+import { randomString, createRandomPage, newBlock, enterNextBlock } from './utils'
 
 test('render app', async ({ page }) => {
   // NOTE: part of app startup tests is moved to `fixtures.ts`.
@@ -18,17 +17,20 @@ test('toggle sidebar', async ({ page }) => {
   // Left sidebar is toggled by `is-open` class
   if (/is-open/.test(await sidebar.getAttribute('class'))) {
     await page.click('#left-menu.button')
-    expect(await sidebar.getAttribute('class')).not.toMatch(/is-open/)
+    await expect(sidebar).not.toHaveClass(/is-open/)
   } else {
     await page.click('#left-menu.button')
-    expect(await sidebar.getAttribute('class')).toMatch(/is-open/)
+    await page.waitForTimeout(10)
+    await expect(sidebar).toHaveClass(/is-open/)
     await page.click('#left-menu.button')
-    expect(await sidebar.getAttribute('class')).not.toMatch(/is-open/)
+    await page.waitForTimeout(10)
+    await expect(sidebar).not.toHaveClass(/is-open/)
   }
 
   await page.click('#left-menu.button')
 
-  expect(await sidebar.getAttribute('class')).toMatch(/is-open/)
+  await page.waitForTimeout(10)
+  await expect(sidebar).toHaveClass(/is-open/)
   await page.waitForSelector('#left-sidebar .left-sidebar-inner', { state: 'visible' })
   await page.waitForSelector('#left-sidebar a:has-text("New page")', { state: 'visible' })
 })
@@ -43,49 +45,48 @@ test('search', async ({ page }) => {
   expect(results.length).toBeGreaterThanOrEqual(1)
 })
 
-test('create page and blocks', async ({ page }) => {
+test('create page and blocks, save to disk', async ({ page, graphDir }) => {
   const pageTitle = await createRandomPage(page)
 
   // do editing
-  await page.fill(':nth-match(textarea, 1)', 'this is my first bullet')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.fill('textarea >> nth=0', 'this is my first bullet')
+  await enterNextBlock(page)
 
-  // first block
-  expect(await page.$$('.block-content')).toHaveLength(1)
+  // wait first block
+  await page.waitForSelector('.ls-block >> nth=0')
 
-  await page.fill(':nth-match(textarea, 1)', 'this is my second bullet')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.fill('textarea >> nth=0', 'this is my second bullet')
+  await enterNextBlock(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'this is my third bullet')
-  await page.press(':nth-match(textarea, 1)', 'Tab')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.fill('textarea >> nth=0', 'this is my third bullet')
+  await page.press('textarea >> nth=0', 'Tab')
+  await enterNextBlock(page)
 
   await page.keyboard.type('continue editing test')
   await page.keyboard.press('Shift+Enter')
   await page.keyboard.type('continue')
 
-  await page.keyboard.press('Enter')
+  await enterNextBlock(page)
   await page.keyboard.press('Shift+Tab')
   await page.keyboard.press('Shift+Tab')
   await page.keyboard.type('test ok')
   await page.keyboard.press('Escape')
 
-  const blocks = await page.$$('.ls-block')
-  expect(blocks).toHaveLength(5)
+  // NOTE: nth= counts from 0, so here're 5 blocks
+  await page.waitForSelector('.ls-block >> nth=4')
 
   // active edit
   await page.click('.ls-block >> nth=-1')
-  await page.press('textarea >> nth=0', 'Enter')
+  await enterNextBlock(page)
   await page.fill('textarea >> nth=0', 'test')
   for (let i = 0; i < 5; i++) {
     await page.keyboard.press('Backspace')
   }
 
   await page.keyboard.press('Escape')
-  await page.waitForTimeout(500)
-  expect(await page.$$('.ls-block')).toHaveLength(5)
+  await page.waitForSelector('.ls-block >> nth=4') // 5 blocks
 
-  await page.waitForTimeout(1000)
+  await page.waitForTimeout(2000) // wait for saving to disk
 
   const contentOnDisk = await fs.readFile(
     path.join(graphDir, `pages/${pageTitle}.md`),
@@ -101,30 +102,31 @@ test('create page and blocks', async ({ page }) => {
 - test ok`.trim())
 })
 
+
 test('delete and backspace', async ({ page }) => {
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'test')
-
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('test')
+  await page.fill('textarea >> nth=0', 'test')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('test')
 
   // backspace
   await page.keyboard.press('Backspace')
   await page.keyboard.press('Backspace')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('te')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('te')
 
   // refill
-  await page.fill(':nth-match(textarea, 1)', 'test')
+  await enterNextBlock(page)
+  await page.type('textarea >> nth=0', 'test')
   await page.keyboard.press('ArrowLeft')
   await page.keyboard.press('ArrowLeft')
 
   // delete
   await page.keyboard.press('Delete')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('tet')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('tet')
   await page.keyboard.press('Delete')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('te')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('te')
   await page.keyboard.press('Delete')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('te')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('te')
 
   // TODO: test delete & backspace across blocks
 })
@@ -133,28 +135,30 @@ test('delete and backspace', async ({ page }) => {
 test('selection', async ({ page }) => {
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'line 1')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.fill(':nth-match(textarea, 1)', 'line 2')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Tab')
-  await page.fill(':nth-match(textarea, 1)', 'line 3')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.fill(':nth-match(textarea, 1)', 'line 4')
-  await page.press(':nth-match(textarea, 1)', 'Tab')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.fill(':nth-match(textarea, 1)', 'line 5')
-
+  // add 5 blocks
+  await page.fill('textarea >> nth=0', 'line 1')
+  await enterNextBlock(page)
+  await page.fill('textarea >> nth=0', 'line 2')
+  await enterNextBlock(page)
+  await page.press('textarea >> nth=0', 'Tab')
+  await page.fill('textarea >> nth=0', 'line 3')
+  await enterNextBlock(page)
+  await page.fill('textarea >> nth=0', 'line 4')
+  await page.press('textarea >> nth=0', 'Tab')
+  await enterNextBlock(page)
+  await page.fill('textarea >> nth=0', 'line 5')
+
+  // shift+up select 3 blocks
   await page.keyboard.down('Shift')
   await page.keyboard.press('ArrowUp')
   await page.keyboard.press('ArrowUp')
   await page.keyboard.press('ArrowUp')
   await page.keyboard.up('Shift')
 
-  await page.waitForTimeout(500)
+  await page.waitForSelector('.ls-block.selected >> nth=2') // 3 selected
   await page.keyboard.press('Backspace')
 
-  expect(await page.$$('.ls-block')).toHaveLength(2)
+  await page.waitForSelector('.ls-block >> nth=1') // 2 blocks
 })
 
 test('template', async ({ page }) => {
@@ -162,140 +166,126 @@ test('template', async ({ page }) => {
 
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'template')
-  await page.press(':nth-match(textarea, 1)', 'Shift+Enter')
-  await page.type(':nth-match(textarea, 1)', 'template:: ' + randomTemplate)
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.fill('textarea >> nth=0', 'template')
+  await page.press('textarea >> nth=0', 'Shift+Enter')
+  await page.type('textarea >> nth=0', 'template:: ' + randomTemplate)
 
-  await page.press(':nth-match(textarea, 1)', 'Tab')
-  await page.fill(':nth-match(textarea, 1)', 'line1')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.fill(':nth-match(textarea, 1)', 'line2')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Tab')
-  await page.fill(':nth-match(textarea, 1)', 'line3')
+  // Enter twice to exit from property block
+  await page.press('textarea >> nth=0', 'Enter')
+  await enterNextBlock(page)
 
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.press('textarea >> nth=0', 'Tab')
+  await page.fill('textarea >> nth=0', 'line1')
+  await enterNextBlock(page)
+  await page.fill('textarea >> nth=0', 'line2')
+  await enterNextBlock(page)
+  await page.press('textarea >> nth=0', 'Tab')
+  await page.fill('textarea >> nth=0', 'line3')
 
+  await enterNextBlock(page)
+  await page.press('textarea >> nth=0', 'Shift+Tab')
+  await page.press('textarea >> nth=0', 'Shift+Tab')
+  await page.press('textarea >> nth=0', 'Shift+Tab')
 
-  expect(await page.$$('.ls-block')).toHaveLength(5)
+  await page.waitForSelector('.ls-block >> nth=3') // total 4 blocks
 
-  await page.type(':nth-match(textarea, 1)', '/template')
+  // NOTE: use delay to type slower, to trigger auto-completion UI.
+  await page.type('textarea >> nth=0', '/template', { delay: 100 })
 
   await page.click('[title="Insert a created template here"]')
   // type to search template name
-  await page.keyboard.type(randomTemplate.substring(0, 3))
+  await page.keyboard.type(randomTemplate.substring(0, 3), { delay: 100 })
+  await page.waitForTimeout(500) // wait for template search
   await page.click('.absolute >> text=' + randomTemplate)
 
-  await page.waitForTimeout(500)
 
-  expect(await page.$$('.ls-block')).toHaveLength(8)
+  await page.waitForSelector('.ls-block >> nth=7') // 8 blocks
 })
 
 test('auto completion square brackets', async ({ page }) => {
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'Auto-completion test')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-
   // [[]]
-  await page.type(':nth-match(textarea, 1)', 'This is a [')
-  await page.inputValue(':nth-match(textarea, 1)').then(text => {
+  await page.type('textarea >> nth=0', 'This is a [')
+  await page.inputValue('textarea >> nth=0').then(text => {
     expect(text).toBe('This is a []')
   })
-  await page.type(':nth-match(textarea, 1)', '[')
+  await page.waitForTimeout(100)
+  await page.type('textarea >> nth=0', '[')
   // wait for search popup
   await page.waitForSelector('text="Search for a page"')
 
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('This is a [[]]')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('This is a [[]]')
 
   // re-enter edit mode
-  await page.press(':nth-match(textarea, 1)', 'Escape')
+  await page.press('textarea >> nth=0', 'Escape')
   await page.click('.ls-block >> nth=-1')
-  await page.waitForSelector(':nth-match(textarea, 1)', { state: 'visible' })
+  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
 
   // #3253
-  await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+  await page.press('textarea >> nth=0', 'Enter')
   await page.waitForSelector('text="Search for a page"', { state: 'visible' })
 
   // type more `]`s
-  await page.type(':nth-match(textarea, 1)', ']')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('This is a [[]]')
-  await page.type(':nth-match(textarea, 1)', ']')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('This is a [[]]')
-  await page.type(':nth-match(textarea, 1)', ']')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('This is a [[]]]')
+  await page.type('textarea >> nth=0', ']')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('This is a [[]]')
+  await page.type('textarea >> nth=0', ']')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('This is a [[]]')
+  await page.type('textarea >> nth=0', ']')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('This is a [[]]]')
 })
 
 test('auto completion and auto pair', async ({ page }) => {
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'Auto-completion test')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await page.fill('textarea >> nth=0', 'Auto-completion test')
+  await enterNextBlock(page)
 
   // {{
-  await page.type(':nth-match(textarea, 1)', 'type {{')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type {{}}')
+  await page.type('textarea >> nth=0', 'type {{')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type {{}}')
 
   // ((
   await newBlock(page)
 
-  await page.type(':nth-match(textarea, 1)', 'type (')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type ()')
-  await page.type(':nth-match(textarea, 1)', '(')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type (())')
+  await page.type('textarea >> nth=0', 'type (')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type ()')
+  await page.type('textarea >> nth=0', '(')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type (())')
 
   // 99  #3444
   // TODO: Test under different keyboard layout when Playwright supports it
   // await newBlock(page)
 
-  // await page.type(':nth-match(textarea, 1)', 'type 9')
-  // expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type 9')
-  // await page.type(':nth-match(textarea, 1)', '9')
-  // expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type 99')
+  // await page.type('textarea >> nth=0', 'type 9')
+  // expect(await page.inputValue('textarea >> nth=0')).toBe('type 9')
+  // await page.type('textarea >> nth=0', '9')
+  // expect(await page.inputValue('textarea >> nth=0')).toBe('type 99')
 
   // [[  #3251
   await newBlock(page)
 
-  await page.type(':nth-match(textarea, 1)', 'type [')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type []')
-  await page.type(':nth-match(textarea, 1)', '[')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type [[]]')
+  await page.type('textarea >> nth=0', 'type [')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type []')
+  await page.type('textarea >> nth=0', '[')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type [[]]')
 
   // ``
   await newBlock(page)
 
-  await page.type(':nth-match(textarea, 1)', 'type `')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type ``')
-  await page.type(':nth-match(textarea, 1)', 'code here')
-
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('type `code here`')
-})
-
+  await page.type('textarea >> nth=0', 'type `')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type ``')
+  await page.type('textarea >> nth=0', 'code here')
 
-// FIXME: Electron with filechooser is not working
-test.skip('open directory', async ({ page }) => {
-  await page.click('#left-sidebar >> text=Journals')
-  await page.waitForSelector('h1:has-text("Open a local directory")')
-  await page.click('h1:has-text("Open a local directory")')
-
-  // await page.waitForEvent('filechooser')
-  await page.keyboard.press('Escape')
-
-  await page.click('#left-sidebar >> text=Journals')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('type `code here`')
 })
 
 test('invalid page props #3944', async ({ page }) => {
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', 'public:: true\nsize:: 65535')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-
-  await page.waitForTimeout(1000)
+  await page.fill('textarea >> nth=0', 'public:: true\nsize:: 65535')
+  await page.press('textarea >> nth=0', 'Enter')
+  await enterNextBlock(page)
 })

+ 3 - 3
e2e-tests/code-editing.spec.ts

@@ -19,7 +19,7 @@ test('switch code editing mode', async ({ page }) => {
   // NOTE: multiple textarea elements are existed in the editor, be careful to select the right one
 
   // code block with 0 line
-  await page.type(':nth-match(textarea, 1)', '```clojure\n')
+  await page.type('textarea >> nth=0', '```clojure\n')
   // line number: 1
   await page.waitForSelector('.CodeMirror pre', { state: 'visible' })
   expect(await page.locator('.CodeMirror-gutter-wrapper .CodeMirror-linenumber').innerText()).toBe('1')
@@ -28,10 +28,10 @@ test('switch code editing mode', async ({ page }) => {
 
   await page.press('.CodeMirror textarea', 'Escape')
   await page.waitForSelector('.CodeMirror pre', { state: 'hidden' })
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('```clojure\n```')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('```clojure\n```')
 
   await page.waitForTimeout(200)
-  await page.press(':nth-match(textarea, 1)', 'Escape')
+  await page.press('textarea >> nth=0', 'Escape')
   await page.waitForSelector('.CodeMirror pre', { state: 'visible' })
 
   // NOTE: must wait here, await loading of CodeMirror editor

+ 82 - 0
e2e-tests/dnd.spec.ts

@@ -0,0 +1,82 @@
+import { expect } from '@playwright/test'
+import { test } from './fixtures'
+import { createRandomPage, enterNextBlock } from './utils'
+
+/**
+ * Drag and Drop tests.
+ *
+ * NOTE: x = 30 is an estimation of left position of the drop target.
+ */
+
+test('drop to left center', async ({ page }) => {
+  await createRandomPage(page)
+
+  await page.fill('textarea >> nth=0', 'block a')
+  await enterNextBlock(page)
+
+  await page.fill('textarea >> nth=0', 'block b')
+  await page.press('textarea >> nth=0', 'Escape')
+
+  const bullet = page.locator('span.bullet-container >> nth=-1')
+  const where = page.locator('.ls-block >> nth=0')
+  await bullet.dragTo(where, {
+    targetPosition: {
+      x: 30,
+      y: (await where.boundingBox()).height * 0.5
+    }
+  })
+
+  await page.keyboard.press('Escape')
+
+  const pageElem = page.locator('.page-blocks-inner')
+  await expect(pageElem).toHaveText('block b\nblock a', {useInnerText: true})
+})
+
+
+test('drop to upper left', async ({ page }) => {
+  await createRandomPage(page)
+
+  await page.fill('textarea >> nth=0', 'block a')
+  await enterNextBlock(page)
+
+  await page.fill('textarea >> nth=0', 'block b')
+  await page.press('textarea >> nth=0', 'Escape')
+
+  const bullet = page.locator('span.bullet-container >> nth=-1')
+  const where = page.locator('.ls-block >> nth=0')
+  await bullet.dragTo(where, {
+    targetPosition: {
+      x: 30,
+      y: 5
+    }
+  })
+
+  await page.keyboard.press('Escape')
+
+  const pageElem = page.locator('.page-blocks-inner')
+  await expect(pageElem).toHaveText('block b\nblock a', {useInnerText: true})
+})
+
+test('drop to bottom left', async ({ page }) => {
+  await createRandomPage(page)
+
+  await page.fill('textarea >> nth=0', 'block a')
+  await enterNextBlock(page)
+
+  await page.fill('textarea >> nth=0', 'block b')
+  await page.press('textarea >> nth=0', 'Escape')
+
+  const bullet = page.locator('span.bullet-container >> nth=-1')
+  const where = page.locator('.ls-block >> nth=0')
+  await bullet.dragTo(where, {
+    targetPosition: {
+      x: 30,
+      y: (await where.boundingBox()).height * 0.75
+    }
+  })
+
+  await page.keyboard.press('Escape')
+
+  const pageElem = page.locator('.page-blocks-inner')
+  await expect(pageElem).toHaveText('block a\nblock b', {useInnerText: true})
+})

+ 127 - 21
e2e-tests/editor.spec.ts

@@ -1,26 +1,132 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { createRandomPage } from './utils'
+import { createRandomPage, enterNextBlock, editFirstBlock, IsMac } from './utils'
+import { dispatch_kb_events } from './util/keyboard-events'
+import * as kb_events from './util/keyboard-events'
 
-test('hashtag and quare brackets in same line #4178', async ({ page }) => {
-    await createRandomPage(page)
-  
-    await page.type(':nth-match(textarea, 1)', '#foo bar')
-    await page.press(':nth-match(textarea, 1)', 'Enter')
-    await page.type(':nth-match(textarea, 1)', 'bar [[blah]]')
-    for (let i = 0; i < 12; i++) {
-      await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
+test(
+  "press Chinese parenthesis 【 by 2 times #3251 should trigger [[]], " +
+  "but dont trigger RIME #3440 ",
+  // cases should trigger [[]] #3251
+  async ({ page }) => {
+    for (let [idx, events] of [
+      kb_events.win10_pinyin_left_full_square_bracket,
+      kb_events.macos_pinyin_left_full_square_bracket
+      // TODO: support #3741
+      // kb_events.win10_legacy_pinyin_left_full_square_bracket,
+    ].entries()) {
+      await createRandomPage(page)
+      let check_text = "#3251 test " + idx
+      await page.fill(':nth-match(textarea, 1)', check_text + "【")
+      await dispatch_kb_events(page, ':nth-match(textarea, 1)', events)
+      expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(check_text + '【')
+      await page.fill(':nth-match(textarea, 1)', check_text + "【【")
+      await dispatch_kb_events(page, ':nth-match(textarea, 1)', events)
+      expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(check_text + '[[]]')
+    };
+
+    // dont trigger RIME #3440
+    for (let [idx, events] of [
+      kb_events.macos_pinyin_selecting_candidate_double_left_square_bracket,
+      kb_events.win10_RIME_selecting_candidate_double_left_square_bracket
+    ].entries()) {
+      await createRandomPage(page)
+      let check_text = "#3440 test " + idx
+      await page.fill(':nth-match(textarea, 1)', check_text)
+      await dispatch_kb_events(page, ':nth-match(textarea, 1)', events)
+      expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(check_text)
+      await dispatch_kb_events(page, ':nth-match(textarea, 1)', events)
+      expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(check_text)
     }
-    await page.type(':nth-match(textarea, 1)', ' ')
-    await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  
-    await page.type(':nth-match(textarea, 1)', '#')
-    await page.waitForSelector('text="Search for a page"', { 'state': 'visible' })
-  
-    await page.type(':nth-match(textarea, 1)', 'fo')
-  
-    await page.click('.absolute >> text=' + 'foo')
-  
-    expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('#foo bar [[blah]]')
   })
-  
+
+test('hashtag and quare brackets in same line #4178', async ({ page }) => {
+  await createRandomPage(page)
+
+  await page.type('textarea >> nth=0', '#foo bar')
+  await enterNextBlock(page)
+  await page.type('textarea >> nth=0', 'bar [[blah]]', { delay: 100})
+
+  for (let i = 0; i < 12; i++) {
+    await page.press('textarea >> nth=0', 'ArrowLeft')
+  }
+  await page.type('textarea >> nth=0', ' ')
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+
+  await page.type('textarea >> nth=0', '#')
+  await page.waitForSelector('text="Search for a page"', { state: 'visible' })
+
+  await page.type('textarea >> nth=0', 'fo')
+
+  await page.click('.absolute >> text=' + 'foo')
+
+  expect(await page.inputValue('textarea >> nth=0')).toBe(
+    '#foo bar [[blah]]'
+  )
+})
+
+test('disappeared children #4814', async ({ page }) => {
+  await createRandomPage(page)
+
+  await page.type('textarea >> nth=0', 'parent')
+  await enterNextBlock(page)
+  await page.press('textarea >> nth=0', 'Tab')
+
+  for (let i = 0; i < 5; i++) {
+    await page.type('textarea >> nth=0', i.toString())
+    await enterNextBlock(page)
+  }
+
+  // collapse
+  await page.click('.block-control >> nth=0')
+
+  // expand
+  await page.click('.block-control >> nth=0')
+
+  await page.waitForSelector('.ls-block >> nth=6') // 7 blocks
+
+  // Ensures there's no active editor
+  await expect(page.locator('.editor-inner')).toHaveCount(0, {timeout: 500})
+})
+
+// FIXME: ClipboardItem is not defined when running with this test
+// test('copy & paste block ref and replace its content', async ({ page }) => {
+//   await createRandomPage(page)
+
+//   await page.type('textarea >> nth=0', 'Some random text')
+//   if (IsMac) {
+//     await page.keyboard.press('Meta+c')
+//   } else {
+//     await page.keyboard.press('Control+c')
+//   }
+
+//   await page.pause()
+
+//   await page.press('textarea >> nth=0', 'Enter')
+//   if (IsMac) {
+//     await page.keyboard.press('Meta+v')
+//   } else {
+//     await page.keyboard.press('Control+v')
+//   }
+//   await page.keyboard.press('Escape')
+
+//   const blockRef$ = page.locator('.block-ref >> text="Some random text"');
+
+//   // Check if the newly created block-ref has the same referenced content
+//   await expect(blockRef$).toHaveCount(1);
+
+//   // Edit the last block
+//   await blockRef$.press('Enter')
+
+//   // Move cursor into the block ref
+//   for (let i = 0; i < 4; i++) {
+//     await page.press('textarea >> nth=0', 'ArrowLeft')
+//   }
+
+//   // Trigger replace-block-reference-with-content-at-point
+//   if (IsMac) {
+//     await page.keyboard.press('Meta+Shift+r')
+//   } else {
+//     await page.keyboard.press('Control+Shift+v')
+//   }
+// })

+ 22 - 11
e2e-tests/fixtures.ts

@@ -1,5 +1,5 @@
-import fs from 'fs'
-import path from 'path'
+import * as fs from 'fs'
+import * as path from 'path'
 import { test as base, expect, ConsoleMessage } from '@playwright/test';
 import { ElectronApplication, Page, BrowserContext, _electron as electron } from 'playwright'
 import { loadLocalGraph, randomString } from './utils';
@@ -17,12 +17,19 @@ if (fs.existsSync(testTmpDir)) {
 
 export let graphDir = path.resolve(testTmpDir, "e2e-test", repoName)
 
-// NOTE: This is a console log watcher for error logs.
+// NOTE: This following is a console log watcher for error logs.
+// Save and print all logs when error happens.
+let logs: string
 const consoleLogWatcher = (msg: ConsoleMessage) => {
   // console.log(msg.text())
-  expect(msg.text()).not.toMatch(/^Failed to/)
-  expect(msg.text()).not.toMatch(/^Error/)
-  expect(msg.text()).not.toMatch(/^Uncaught/)
+  logs += msg.text() + '\n'
+    expect(msg.text(), logs).not.toMatch(/^(Failed to|Uncaught)/)
+
+  // youtube video
+  if (!logs.match(/^Error with Permissions-Policy header: Unrecognized feature/)) {
+    expect(logs).not.toMatch(/^Error/)
+  }
+
   // NOTE: React warnings will be logged as error.
   // expect(msg.type()).not.toBe('error')
 }
@@ -32,6 +39,7 @@ base.beforeAll(async () => {
     return
   }
 
+  console.log(`Creating test graph directory: ${graphDir}`)
   fs.mkdirSync(graphDir, {
     recursive: true,
   });
@@ -60,15 +68,15 @@ base.beforeAll(async () => {
   // Direct Electron console to watcher
   page.on('console', consoleLogWatcher)
   page.on('crash', () => {
-    expect('page must not crash!').toBe('page crashed')
+    expect(false, "Page must not crash").toBeTruthy()
   })
   page.on('pageerror', (err) => {
     console.log(err)
-    expect('page must not have errors!').toBe('page has some error')
+    expect(false, 'Page must not have errors!').toBeTruthy()
   })
 
   await page.waitForLoadState('domcontentloaded')
-  await page.waitForFunction('window.document.title != "Loading"')
+  // await page.waitForFunction(() => window.document.title != "Loading")
   // NOTE: The following ensures first start.
   // await page.waitForSelector('text=This is a demo graph, changes will not be saved until you open a local folder')
 
@@ -100,7 +108,7 @@ base.afterAll(async () => {
 })
 
 // hijack electron app into the test context
-export const test = base.extend<{ page: Page, context: BrowserContext, app: ElectronApplication }>({
+export const test = base.extend<{ page: Page, context: BrowserContext, app: ElectronApplication, graphDir: string }>({
   page: async ({ }, use) => {
     await use(page);
   },
@@ -109,5 +117,8 @@ export const test = base.extend<{ page: Page, context: BrowserContext, app: Elec
   },
   app: async ({ }, use) => {
     await use(electronApp);
-  }
+  },
+  graphDir: async ({ }, use) => {
+    await use(graphDir);
+  },
 });

+ 18 - 18
e2e-tests/hotkey.spec.ts

@@ -8,8 +8,7 @@ test('open search dialog', async ({ page }) => {
   } else if (IsLinux) {
     await page.keyboard.press('Control+k')
   } else {
-    // TODO: test on Windows and other platforms
-    expect(false)
+    expect(false, "TODO: test on Windows and other platforms").toBeTruthy()
   }
 
   await page.waitForSelector('[placeholder="Search or create page"]')
@@ -30,26 +29,27 @@ test('insert link', async ({ page }) => {
 
   // Case 1: empty link
   await lastBlock(page)
-  await page.press(':nth-match(textarea, 1)', hotKey)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[]()')
-  await page.type(':nth-match(textarea, 1)', 'Logseq Website')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[Logseq Website]()')
+  await page.press('textarea >> nth=0', hotKey)
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[]()')
+  await page.type('textarea >> nth=0', 'Logseq Website')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[Logseq Website]()')
+  await page.fill('textarea >> nth=0', '[Logseq Website](https://logseq.com)')
 
   // Case 2: link with label
   await newBlock(page)
-  await page.type(':nth-match(textarea, 1)', 'Logseq')
-  await page.press(':nth-match(textarea, 1)', selectAll)
-  await page.press(':nth-match(textarea, 1)', hotKey)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[Logseq]()')
-  await page.type(':nth-match(textarea, 1)', 'https://logseq.com/')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[Logseq](https://logseq.com/)')
+  await page.type('textarea >> nth=0', 'Logseq')
+  await page.press('textarea >> nth=0', selectAll)
+  await page.press('textarea >> nth=0', hotKey)
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[Logseq]()')
+  await page.type('textarea >> nth=0', 'https://logseq.com/')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[Logseq](https://logseq.com/)')
 
   // Case 3: link with URL
   await newBlock(page)
-  await page.type(':nth-match(textarea, 1)', 'https://logseq.com/')
-  await page.press(':nth-match(textarea, 1)', selectAll)
-  await page.press(':nth-match(textarea, 1)', hotKey)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[](https://logseq.com/)')
-  await page.type(':nth-match(textarea, 1)', 'Logseq')
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe('[Logseq](https://logseq.com/)')
+  await page.type('textarea >> nth=0', 'https://logseq.com/')
+  await page.press('textarea >> nth=0', selectAll)
+  await page.press('textarea >> nth=0', hotKey)
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[](https://logseq.com/)')
+  await page.type('textarea >> nth=0', 'Logseq')
+  expect(await page.inputValue('textarea >> nth=0')).toBe('[Logseq](https://logseq.com/)')
 })

+ 5 - 3
e2e-tests/page-rename.spec.ts

@@ -1,6 +1,6 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { IsMac, createPage, newBlock, newInnerBlock, randomString, lastInnerBlock } from './utils'
+import { IsMac, createPage, newBlock, newInnerBlock, randomString, lastBlock } from './utils'
 
 /***
  * Test rename feature
@@ -18,7 +18,9 @@ async function page_rename_test(page, original_page_name: string, new_page_name:
 
   await createPage(page, original_name)
   await page.click('.page-title .title')
+  await page.waitForSelector('input[type="text"]')
   await page.keyboard.press(selectAll)
+  await page.keyboard.press('Backspace')
   await page.type('.title input', new_name)
   await page.keyboard.press('Enter')
   await page.click('.ui__confirm-modal button')
@@ -26,11 +28,11 @@ async function page_rename_test(page, original_page_name: string, new_page_name:
   expect(await page.innerText('.page-title .title')).toBe(new_name)
 
   // TODO: Test if page is renamed in re-entrance
-  
+
   // TODO: Test if page is hierarchy
 }
 
 test('page rename test', async ({ page }) => {
   await page_rename_test(page, "abcd", "a.b.c.d")
   await page_rename_test(page, "abcd", "a/b/c/d")
-})
+})

+ 43 - 32
e2e-tests/page-search.spec.ts

@@ -1,6 +1,6 @@
-import { expect } from '@playwright/test'
+import { expect, Page } from '@playwright/test'
 import { test } from './fixtures'
-import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastInnerBlock, activateNewPage } from './utils'
+import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastBlock, enterNextBlock } from './utils'
 
 /***
  * Test alias features
@@ -21,15 +21,15 @@ import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastInn
   // diacritic opening test
   await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-1')
+  await page.fill('textarea >> nth=0', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-1')
   await page.keyboard.press(hotkeyOpenLink)
 
   // build target Page with diacritics
-  await activateNewPage(page)
-  await page.type(':nth-match(textarea, 1)', 'Diacritic title test content')
+  await lastBlock(page)
+  await page.type('textarea >> nth=0', 'Diacritic title test content')
 
   await page.keyboard.press('Enter')
-  await page.fill(':nth-match(textarea, 1)', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-2')
+  await page.fill('textarea >> nth=0', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-2')
   await page.keyboard.press(hotkeyBack)
 
   // check if diacritics are indexed
@@ -43,7 +43,7 @@ import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastInn
   await page.keyboard.press("Escape")
 })
 
-async function alias_test(page, page_name: string, search_kws: string[]) {
+async function alias_test(page: Page, page_name: string, search_kws: string[]) {
   let hotkeyOpenLink = 'Control+o'
   let hotkeyBack = 'Control+['
   if (IsMac) {
@@ -61,45 +61,56 @@ async function alias_test(page, page_name: string, search_kws: string[]) {
   // shortcut opening test
   let parent_title = await createRandomPage(page)
 
-  await page.fill(':nth-match(textarea, 1)', '[[' + target_name + ']]')
+  await page.fill('textarea >> nth=0', '[[' + target_name + ']]')
   await page.keyboard.press(hotkeyOpenLink)
 
+  await lastBlock(page)
+  await page.waitForTimeout(500)
+
   // build target Page with alias
-  await page.type(':nth-match(textarea, 1)', 'alias:: [[' + alias_name + ']]')
-  await page.press(':nth-match(textarea, 1)', 'Enter') // double Enter for exit property editing
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await page.type(':nth-match(textarea, 1)', alias_test_content_1)
+  await page.type('textarea >> nth=0', 'alias:: [[' + alias_name)
+  await page.press('textarea >> nth=0', 'ArrowRight')
+  await page.press('textarea >> nth=0', 'ArrowRight')
+  await page.press('textarea >> nth=0', 'Enter') // double Enter for exit property editing
+  await page.press('textarea >> nth=0', 'Enter') // double Enter for exit property editing
+  await page.waitForTimeout(500)
+  await page.type('textarea >> nth=0', alias_test_content_1)
   await page.keyboard.press(hotkeyBack)
 
+  await page.waitForTimeout(100) // await navigation
   // create alias ref in origin Page
   await newBlock(page)
-  await page.type(':nth-match(textarea, 1)', '[[' + alias_name + ']]')
+  await page.type('textarea >> nth=0', '[[' + alias_name)
+  await page.waitForTimeout(100)
+
   await page.keyboard.press(hotkeyOpenLink)
+  await page.waitForTimeout(100) // await navigation
 
   // shortcut opening test
-  await lastInnerBlock(page)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(alias_test_content_1)
-  await newInnerBlock(page)
-  await page.type(':nth-match(textarea, 1)', alias_test_content_2)
+  await lastBlock(page)
+  expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_1)
+
+  await enterNextBlock(page)
+  await page.type('textarea >> nth=0', alias_test_content_2)
   await page.keyboard.press(hotkeyBack)
 
   // pressing enter opening test
-  await lastInnerBlock(page)
-  await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  await page.press(':nth-match(textarea, 1)', 'ArrowLeft')
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-  await lastInnerBlock(page)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(alias_test_content_2)
+  await lastBlock(page)
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+  await page.press('textarea >> nth=0', 'ArrowLeft')
+  await page.press('textarea >> nth=0', 'Enter')
+  await lastBlock(page)
+  expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_2)
   await newInnerBlock(page)
-  await page.type(':nth-match(textarea, 1)', alias_test_content_3)
+  await page.type('textarea >> nth=0', alias_test_content_3)
   await page.keyboard.press(hotkeyBack)
 
   // clicking opening test
   await page.waitForSelector('.page-blocks-inner .ls-block .page-ref >> nth=-1')
   await page.click('.page-blocks-inner .ls-block .page-ref >> nth=-1')
-  await lastInnerBlock(page)
-  expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(alias_test_content_3)
+  await lastBlock(page)
+  expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_3)
 
   // TODO: test alias from graph clicking
 
@@ -127,8 +138,8 @@ async function alias_test(page, page_name: string, search_kws: string[]) {
     page.keyboard.press("Enter")
     await page.waitForNavigation()
     await page.waitForTimeout(100)
-    await lastInnerBlock(page)
-    expect(await page.inputValue(':nth-match(textarea, 1)')).toBe(alias_test_content_3)
+    await lastBlock(page)
+    expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_3)
 
     // test search clicking (block)
     await page.click('#search-button')
@@ -138,8 +149,8 @@ async function alias_test(page, page_name: string, search_kws: string[]) {
     page.click(":nth-match(.menu-link, 2)")
     await page.waitForNavigation()
     await page.waitForTimeout(500)
-    await lastInnerBlock(page)
-    expect(await page.inputValue(':nth-match(textarea, 1)')).toBe("[[" + alias_name + "]]")
+    await lastBlock(page)
+    expect(await page.inputValue('textarea >> nth=0')).toBe("[[" + alias_name + "]]")
     await page.keyboard.press(hotkeyBack)
   }
 
@@ -148,4 +159,4 @@ async function alias_test(page, page_name: string, search_kws: string[]) {
 
 test('page diacritic alias', async ({ page }) => {
   await alias_test(page, "ü", ["ü", "ü", "Ü"])
-})
+})

+ 4 - 3
e2e-tests/sidebar.spec.ts

@@ -1,12 +1,13 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { createRandomPage, searchAndJumpToPage } from './utils'
+import { createRandomPage, openLeftSidebar, searchAndJumpToPage } from './utils'
 
 /***
  * Test side bar features
  ***/
 
 test('favorite item and recent item test', async ({ page }) => {
+  await openLeftSidebar(page)
   // add page to fav
   const fav_page_name = await createRandomPage(page)
   let favs = await page.$$('.favorite-item a')
@@ -37,10 +38,10 @@ test('favorite item and recent item test', async ({ page }) => {
 
 test('recent is updated #4320', async ({ page }) => {
   const page1 = await createRandomPage(page)
-  await page.fill(':nth-match(textarea, 1)', 'Random Thought')
+  await page.fill('textarea >> nth=0', 'Random Thought')
 
   const page2 = await createRandomPage(page)
-  await page.fill(':nth-match(textarea, 1)', 'Another Random Thought')
+  await page.fill('textarea >> nth=0', 'Another Random Thought')
 
   const firstRecent = page.locator('.nav-content-item.recent li >> nth=0')
   expect(await firstRecent.textContent()).toContain(page2)

+ 64 - 0
e2e-tests/util/keyboard-event-cap.html

@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+  <script>
+    'use strict';
+
+    const keys = [
+      // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
+      // without deprecated / non-standard
+      "altKey", "code", "ctrlKey", "isComposing", "key", "locale", "location", "metaKey",
+      "repeat", "shiftKey"
+    ]
+
+    let output_list = [];
+    let last_timestamp = Date.now();
+
+    function select_keys(obj, keys) {
+      let new_obj = {}
+      for (let k in event)
+        if (keys.indexOf(k) != -1)
+          new_obj[k] = event[k];
+      return new_obj
+    }
+
+    let key_handler_builder = (event_type) => (event) => {
+      if (event["target"].id != "input")
+        return;
+      let cur_timestamp = Date.now();
+      let output = {
+        "event_type": event_type,
+        "event": select_keys(event, keys),
+        "latency": cur_timestamp - last_timestamp // Time to wait before firing event
+      }
+      last_timestamp = cur_timestamp;
+      output_list.push(output);
+      let to_print = JSON.stringify(
+        output_list,
+        undefined,
+        2);
+      document.getElementById("outputs").innerText = to_print;
+    }
+
+    document.addEventListener('keydown', key_handler_builder('keydown'), false);
+    document.addEventListener('keyup', key_handler_builder('keyup'), false);
+    document.addEventListener('keypress', key_handler_builder('keypress'), false);
+    document.addEventListener('compositionstart', key_handler_builder('compositionstart'), false);
+    document.addEventListener('compositionend', key_handler_builder('compositionend'), false);
+    document.addEventListener('compositionupdate', key_handler_builder('compositionupdate'), false);
+
+    window.onload = (e) => {
+      document.getElementById("input").focus();
+    }
+
+  </script>
+</head>
+
+<body>
+  <input id="input" />
+  <h2>Key Down</h2>
+  <p id="outputs" style="white-space: pre;" />
+</body>
+
+</html>

+ 466 - 0
e2e-tests/util/keyboard-events.ts

@@ -0,0 +1,466 @@
+/*** 
+ * Author: Junyi Du <[email protected]>
+ * References:
+ * https://stackoverflow.com/questions/8892238/detect-keyboard-layout-with-javascript
+ * ***/
+
+import { Page } from '@playwright/test'
+
+interface RecordedEvent {
+  event_type: string;
+  event: any; // KeyboardEvent is too heavy
+  latency: number;
+}
+
+export let dispatch_kb_events = async function (page: Page, selector: string, keyboard_events: RecordedEvent[] ){
+  for (let kbev of keyboard_events){
+    let { event_type, event, latency } = kbev
+    await page.waitForTimeout(latency)
+    await page.dispatchEvent(selector, event_type, event)
+  }
+}
+
+export let macos_pinyin_left_full_square_bracket: RecordedEvent[] = [
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 0
+  },
+  {
+    "event_type": "keypress",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 1
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 17
+  }
+]
+
+export let win10_pinyin_left_full_square_bracket: RecordedEvent[] = [
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "Process",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 0
+  },
+  {
+    "event_type": "compositionstart",
+    "event": {},
+    "latency": 4
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 12
+  },
+  {
+    "event_type": "compositionend",
+    "event": {},
+    "latency": 1
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "Process",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 61
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "[",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 1
+  }
+]
+
+export let win10_legacy_pinyin_left_full_square_bracket: RecordedEvent[] = [
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "Process",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 0
+  },
+  {
+    "event_type": "compositionstart",
+    "event": {},
+    "latency": 1
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "compositionend",
+    "event": {},
+    "latency": 1
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "Process",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 93
+  }
+]
+
+export let macos_pinyin_selecting_candidate_double_left_square_bracket: RecordedEvent[] = [
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "b",
+      "code": "KeyB",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 0
+  },
+  {
+    "event_type": "compositionstart",
+    "event": {},
+    "latency": 1
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "b",
+      "code": "KeyB",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 48
+  },
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "】",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 627
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "】",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 59
+  },
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "】",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 289
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "】",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 73
+  },
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 443
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 79
+  },
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 155
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "【",
+      "code": "BracketLeft",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 44
+  },
+  {
+    "event_type": "compositionend",
+    "event": {},
+    "latency": 968
+  }
+]
+
+export let win10_RIME_selecting_candidate_double_left_square_bracket: RecordedEvent[] = [
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "Process",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": false
+    },
+    "latency": 0
+  },
+  {
+    "event_type": "compositionstart",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "compositionupdate",
+    "event": {},
+    "latency": 0
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "Process",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 79
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "]",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 3
+  },
+  {
+    "event_type": "keydown",
+    "event": {
+      "key": "Process",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 237
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "Process",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 96
+  },
+  {
+    "event_type": "keyup",
+    "event": {
+      "key": "]",
+      "code": "BracketRight",
+      "location": 0,
+      "ctrlKey": false,
+      "shiftKey": false,
+      "altKey": false,
+      "metaKey": false,
+      "repeat": false,
+      "isComposing": true
+    },
+    "latency": 3
+  },
+  {
+    "event_type": "compositionend",
+    "event": {},
+    "latency": 1479
+  }
+]

+ 61 - 32
e2e-tests/utils.ts

@@ -1,6 +1,6 @@
 import { Page, Locator } from 'playwright'
 import { expect } from '@playwright/test'
-import process from 'process'
+import * as process from 'process'
 
 export const IsMac = process.platform === 'darwin'
 export const IsLinux = process.platform === 'linux'
@@ -28,7 +28,7 @@ export async function createRandomPage(page: Page) {
   // Click text=/.*New page: "new page".*/
   await page.click('text=/.*New page: ".*/')
   // wait for textarea of first block
-  await page.waitForSelector(':nth-match(textarea, 1)', { state: 'visible' })
+  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
 
   return randomTitle;
 }
@@ -40,7 +40,7 @@ export async function createPage(page: Page, page_name: string) {// Click #searc
   // Click text=/.*New page: "new page".*/
   await page.click('text=/.*New page: ".*/')
   // wait for textarea of first block
-  await page.waitForSelector(':nth-match(textarea, 1)', { state: 'visible' })
+  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
 
   return page_name;
 }
@@ -59,26 +59,31 @@ export async function searchAndJumpToPage(page: Page, pageTitle: string) {
 * @param page The Playwright Page object.
 * @returns The locator of the last block.
 */
-export async function lastInnerBlock(page: Page): Promise<Locator> {
-  // discard any popups
-  await page.keyboard.press('Escape')
-  // click last block
-  await page.waitForSelector('.page-blocks-inner .ls-block >> nth=-1')
-  await page.click('.page-blocks-inner .ls-block >> nth=-1')
-  // wait for textarea
-  await page.waitForSelector(':nth-match(textarea, 1)', { state: 'visible' })
-  return page.locator(':nth-match(textarea, 1)')
-}
-
 export async function lastBlock(page: Page): Promise<Locator> {
   // discard any popups
   await page.keyboard.press('Escape')
   // click last block
-  await page.click('.ls-block >> nth=-1')
+  if (await page.locator('text="Click here to edit..."').isVisible()) {
+    await page.click('text="Click here to edit..."')
+  } else {
+    await page.click('.page-blocks-inner .ls-block >> nth=-1')
+  }
   // wait for textarea
-  await page.waitForSelector(':nth-match(textarea, 1)', { state: 'visible' })
+  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
+  await page.waitForTimeout(100)
+  return page.locator('textarea >> nth=0')
+}
 
-  return page.locator(':nth-match(textarea, 1)')
+/**
+ * Press Enter and create the next block.
+ * @param page The Playwright Page object.
+ */
+export async function enterNextBlock(page: Page): Promise<Locator> {
+  let blockCount = await page.locator('.page-blocks-inner .ls-block').count()
+  await page.press('textarea >> nth=0', 'Enter')
+  await page.waitForTimeout(10)
+  await page.waitForSelector(`.ls-block >> nth=${blockCount} >> textarea`, { state: 'visible' })
+  return page.locator('textarea >> nth=0')
 }
 
 /**
@@ -87,17 +92,18 @@ export async function lastBlock(page: Page): Promise<Locator> {
 * @returns The locator of the last block
 */
 export async function newInnerBlock(page: Page): Promise<Locator> {
-  await lastInnerBlock(page)
-  await page.press(':nth-match(textarea, 1)', 'Enter')
+  await lastBlock(page)
+  await page.press('textarea >> nth=0', 'Enter')
 
-  return page.locator(':nth-match(textarea, 1)')
+  return page.locator('textarea >> nth=0')
 }
 
 export async function newBlock(page: Page): Promise<Locator> {
-  await lastBlock(page)
-  await page.press(':nth-match(textarea, 1)', 'Enter')
-
-  return page.locator(':nth-match(textarea, 1)')
+  let blockNumber = await page.locator('.page-blocks-inner .ls-block').count()
+  const prev = await lastBlock(page)
+  await page.press('textarea >> nth=0', 'Enter')
+  await page.waitForSelector(`.page-blocks-inner .ls-block >> nth=${blockNumber} >> textarea`, { state: 'visible' })
+  return page.locator('textarea >> nth=0')
 }
 
 export async function escapeToCodeEditor(page: Page): Promise<void> {
@@ -135,27 +141,41 @@ export async function setMockedOpenDirPath(
   )
 }
 
+export async function openLeftSidebar(page: Page): Promise<void> {
+  let sidebar = page.locator('#left-sidebar')
+
+  // Left sidebar is toggled by `is-open` class
+  if (!/is-open/.test(await sidebar.getAttribute('class'))) {
+    await page.click('#left-menu.button')
+    await page.waitForTimeout(10)
+    await expect(sidebar).toHaveClass(/is-open/)
+  }
+}
+
 export async function loadLocalGraph(page: Page, path?: string): Promise<void> {
   await setMockedOpenDirPath(page, path);
 
-  await page.click('#left-menu.button')
-  const hasOpenButton = await page.$('#head >> .button >> text=Open')
+  const onboardingOpenButton = page.locator('strong:has-text("Choose a folder")')
 
-  if (hasOpenButton) {
-    await page.click('#head >> .button >> text=Open')
+  if (await onboardingOpenButton.isVisible()) {
+    await onboardingOpenButton.click()
   } else {
+    await page.click('#left-menu.button')
     let sidebar = page.locator('#left-sidebar')
     if (!/is-open/.test(await sidebar.getAttribute('class'))) {
       await page.click('#left-menu.button')
-      expect(await sidebar.getAttribute('class')).toMatch(/is-open/)
+      await expect(sidebar).toHaveClass(/is-open/)
     }
 
     await page.click('#left-sidebar #repo-switch');
     await page.waitForSelector('#left-sidebar .dropdown-wrapper >> text="Add new graph"', { state: 'visible' })
 
     await page.click('text=Add new graph')
-    await page.waitForSelector('h1:has-text("Open a local directory")', { state: 'visible' })
-    await page.click('h1:has-text("Open a local directory")')
+    await page.waitForSelector('strong:has-text("Choose a folder")', { state: 'visible' })
+    await page.click('strong:has-text("Choose a folder")')
+
+    const skip = page.locator('a:has-text("Skip")')
+    await skip.click()
   }
 
   setMockedOpenDirPath(page, ''); // reset it
@@ -165,7 +185,12 @@ export async function loadLocalGraph(page: Page, path?: string): Promise<void> {
     timeout: 1000 * 60 * 5,
   })
 
-  await page.waitForFunction('window.document.title != "Loading"')
+  const title = await page.title()
+  if (title === "Import data into Logseq" || title === "Add another repo") {
+    await page.click('a.button >> text=Skip')
+  }
+
+  await page.waitForFunction('window.document.title === "Logseq"')
 
   console.log('Graph loaded for ' + path)
 }
@@ -174,3 +199,7 @@ export async function activateNewPage(page: Page) {
   await page.click('.ls-block >> nth=0')
   await page.waitForTimeout(500)
 }
+
+export async function editFirstBlock(page: Page) {
+  await page.click('.ls-block .block-content >> nth=0')
+}

+ 13 - 6
gulpfile.js

@@ -45,12 +45,19 @@ const common = {
     return gulp.src(resourceFilePath).pipe(gulp.dest(outputPath))
   },
 
-  syncAssetFiles () {
-    return gulp.src([
-        "./node_modules/@excalidraw/excalidraw/dist/excalidraw-assets/**",
-        "!**/*/i18n-*.js"
-      ])
-      .pipe(gulp.dest(path.join(outputPath, 'js', 'excalidraw-assets')))
+  // NOTE: All assets from node_modules are copied to the output directory
+  syncAssetFiles (...params) {
+    return gulp.series(
+      () => gulp.src([
+          "./node_modules/@excalidraw/excalidraw/dist/excalidraw-assets/**",
+          "!**/*/i18n-*.js"
+        ])
+        .pipe(gulp.dest(path.join(outputPath, 'js', 'excalidraw-assets'))),
+      () => gulp.src("node_modules/@tabler/icons/iconfont/tabler-icons.min.css")
+        .pipe(gulp.dest(path.join(outputPath, 'css'))),
+      () => gulp.src("node_modules/@tabler/icons/iconfont/fonts/**")
+        .pipe(gulp.dest(path.join(outputPath, 'css', 'fonts'))),
+    )(...params)
   },
 
   keepSyncResourceFile () {

+ 179 - 7
ios/App/App.xcodeproj/project.pbxproj

@@ -16,6 +16,9 @@
 		50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
 		5FD5BB71278579F5008E6875 /* DownloadiCloudFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FD5BB70278579F5008E6875 /* DownloadiCloudFiles.swift */; };
 		5FD5BB73278579FF008E6875 /* DownloadiCloudFiles.m in Sources */ = {isa = PBXBuildFile; fileRef = 5FD5BB72278579FF008E6875 /* DownloadiCloudFiles.m */; };
+		5FFF7D6D27E343FA00B00DA8 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FFF7D6C27E343FA00B00DA8 /* ShareViewController.swift */; };
+		5FFF7D7027E343FA00B00DA8 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5FFF7D6E27E343FA00B00DA8 /* MainInterface.storyboard */; };
+		5FFF7D7427E343FA00B00DA8 /* ShareViewController.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5FFF7D6A27E343FA00B00DA8 /* ShareViewController.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		7435D10C2704659F00AB88E0 /* FolderPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7435D10B2704659F00AB88E0 /* FolderPicker.swift */; };
 		7435D10F2704660B00AB88E0 /* FolderPicker.m in Sources */ = {isa = PBXBuildFile; fileRef = 7435D10E2704660B00AB88E0 /* FolderPicker.m */; };
 		C3718FCEFAECFFB66E93FFC4 /* Pods_Logseq.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2E26D73EA097D0B3B22942E /* Pods_Logseq.framework */; };
@@ -26,6 +29,30 @@
 		FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
 /* End PBXBuildFile section */
 
+/* Begin PBXContainerItemProxy section */
+		5FFF7D7227E343FA00B00DA8 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 504EC2FC1FED79650016851F /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 5FFF7D6927E343FA00B00DA8;
+			remoteInfo = ShareViewController;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+		5FFF7D7527E343FA00B00DA8 /* Embed App Extensions */ = {
+			isa = PBXCopyFilesBuildPhase;
+			buildActionMask = 2147483647;
+			dstPath = "";
+			dstSubfolderSpec = 13;
+			files = (
+				5FFF7D7427E343FA00B00DA8 /* ShareViewController.appex in Embed App Extensions */,
+			);
+			name = "Embed App Extensions";
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXCopyFilesBuildPhase section */
+
 /* Begin PBXFileReference section */
 		2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
 		50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -38,6 +65,11 @@
 		50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
 		5FD5BB70278579F5008E6875 /* DownloadiCloudFiles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadiCloudFiles.swift; sourceTree = "<group>"; };
 		5FD5BB72278579FF008E6875 /* DownloadiCloudFiles.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DownloadiCloudFiles.m; sourceTree = "<group>"; };
+		5FFF7D6A27E343FA00B00DA8 /* ShareViewController.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareViewController.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+		5FFF7D6C27E343FA00B00DA8 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
+		5FFF7D6F27E343FA00B00DA8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
+		5FFF7D7127E343FA00B00DA8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
+		5FFF7D7927E4E70700B00DA8 /* ShareViewController.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareViewController.entitlements; sourceTree = "<group>"; };
 		7435D10B2704659F00AB88E0 /* FolderPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderPicker.swift; sourceTree = "<group>"; };
 		7435D10D2704660A00AB88E0 /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
 		7435D10E2704660B00AB88E0 /* FolderPicker.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FolderPicker.m; sourceTree = "<group>"; };
@@ -63,6 +95,13 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		5FFF7D6727E343FA00B00DA8 /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
@@ -70,6 +109,7 @@
 			isa = PBXGroup;
 			children = (
 				504EC3061FED79650016851F /* App */,
+				5FFF7D6B27E343FA00B00DA8 /* ShareViewController */,
 				504EC3051FED79650016851F /* Products */,
 				D337740F89DEEAD18C87762B /* Pods */,
 				9FC5AB18C7E7E43B09B33A61 /* Frameworks */,
@@ -80,6 +120,7 @@
 			isa = PBXGroup;
 			children = (
 				504EC3041FED79650016851F /* Logseq.app */,
+				5FFF7D6A27E343FA00B00DA8 /* ShareViewController.appex */,
 			);
 			name = Products;
 			sourceTree = "<group>";
@@ -110,6 +151,17 @@
 			path = App;
 			sourceTree = "<group>";
 		};
+		5FFF7D6B27E343FA00B00DA8 /* ShareViewController */ = {
+			isa = PBXGroup;
+			children = (
+				5FFF7D7927E4E70700B00DA8 /* ShareViewController.entitlements */,
+				5FFF7D6C27E343FA00B00DA8 /* ShareViewController.swift */,
+				5FFF7D6E27E343FA00B00DA8 /* MainInterface.storyboard */,
+				5FFF7D7127E343FA00B00DA8 /* Info.plist */,
+			);
+			path = ShareViewController;
+			sourceTree = "<group>";
+		};
 		9FC5AB18C7E7E43B09B33A61 /* Frameworks */ = {
 			isa = PBXGroup;
 			children = (
@@ -140,23 +192,42 @@
 				504EC3011FED79650016851F /* Frameworks */,
 				504EC3021FED79650016851F /* Resources */,
 				4BF32F1E9453A6AB603D7CD2 /* [CP] Embed Pods Frameworks */,
+				5FFF7D7527E343FA00B00DA8 /* Embed App Extensions */,
 			);
 			buildRules = (
 			);
 			dependencies = (
+				5FFF7D7327E343FA00B00DA8 /* PBXTargetDependency */,
 			);
 			name = Logseq;
 			productName = App;
 			productReference = 504EC3041FED79650016851F /* Logseq.app */;
 			productType = "com.apple.product-type.application";
 		};
+		5FFF7D6927E343FA00B00DA8 /* ShareViewController */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 5FFF7D7827E343FA00B00DA8 /* Build configuration list for PBXNativeTarget "ShareViewController" */;
+			buildPhases = (
+				5FFF7D6627E343FA00B00DA8 /* Sources */,
+				5FFF7D6727E343FA00B00DA8 /* Frameworks */,
+				5FFF7D6827E343FA00B00DA8 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = ShareViewController;
+			productName = ShareViewController;
+			productReference = 5FFF7D6A27E343FA00B00DA8 /* ShareViewController.appex */;
+			productType = "com.apple.product-type.app-extension";
+		};
 /* End PBXNativeTarget section */
 
 /* Begin PBXProject section */
 		504EC2FC1FED79650016851F /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastSwiftUpdateCheck = 0920;
+				LastSwiftUpdateCheck = 1330;
 				LastUpgradeCheck = 1310;
 				TargetAttributes = {
 					504EC3031FED79650016851F = {
@@ -164,6 +235,10 @@
 						LastSwiftMigration = 1250;
 						ProvisioningStyle = Automatic;
 					};
+					5FFF7D6927E343FA00B00DA8 = {
+						CreatedOnToolsVersion = 13.3;
+						ProvisioningStyle = Automatic;
+					};
 				};
 			};
 			buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -180,6 +255,7 @@
 			projectRoot = "";
 			targets = (
 				504EC3031FED79650016851F /* Logseq */,
+				5FFF7D6927E343FA00B00DA8 /* ShareViewController */,
 			);
 		};
 /* End PBXProject section */
@@ -198,6 +274,14 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		5FFF7D6827E343FA00B00DA8 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5FFF7D7027E343FA00B00DA8 /* MainInterface.storyboard in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXResourcesBuildPhase section */
 
 /* Begin PBXShellScriptBuildPhase section */
@@ -257,8 +341,24 @@
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
+		5FFF7D6627E343FA00B00DA8 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5FFF7D6D27E343FA00B00DA8 /* ShareViewController.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
 /* End PBXSourcesBuildPhase section */
 
+/* Begin PBXTargetDependency section */
+		5FFF7D7327E343FA00B00DA8 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 5FFF7D6927E343FA00B00DA8 /* ShareViewController */;
+			targetProxy = 5FFF7D7227E343FA00B00DA8 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
 /* Begin PBXVariantGroup section */
 		504EC30B1FED79650016851F /* Main.storyboard */ = {
 			isa = PBXVariantGroup;
@@ -276,6 +376,14 @@
 			name = LaunchScreen.storyboard;
 			sourceTree = "<group>";
 		};
+		5FFF7D6E27E343FA00B00DA8 /* MainInterface.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				5FFF7D6F27E343FA00B00DA8 /* Base */,
+			);
+			name = MainInterface.storyboard;
+			sourceTree = "<group>";
+		};
 /* End PBXVariantGroup section */
 
 /* Begin XCBuildConfiguration section */
@@ -396,16 +504,17 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */;
 			buildSettings = {
+				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 16;
 				DEVELOPMENT_TEAM = K378MFWK59;
 				INFOPLIST_FILE = App/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.0.1;
+				MARKETING_VERSION = 0.0.7;
 				OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -421,16 +530,17 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = 8A489CEC51E94726DDD58810 /* Pods-Logseq.release.xcconfig */;
 			buildSettings = {
+				ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = 16;
 				DEVELOPMENT_TEAM = K378MFWK59;
 				INFOPLIST_FILE = App/Info.plist;
-				IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.0.1;
+				MARKETING_VERSION = 0.0.7;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -440,6 +550,59 @@
 			};
 			name = Release;
 		};
+		5FFF7D7627E343FA00B00DA8 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 16;
+				DEVELOPMENT_TEAM = K378MFWK59;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = ShareViewController/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
+				MARKETING_VERSION = 0.0.7;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SKIP_INSTALL = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		5FFF7D7727E343FA00B00DA8 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CODE_SIGN_ENTITLEMENTS = ShareViewController/ShareViewController.entitlements;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 16;
+				DEVELOPMENT_TEAM = K378MFWK59;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_FILE = ShareViewController/Info.plist;
+				INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
+				INFOPLIST_KEY_NSHumanReadableCopyright = "";
+				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
+				MARKETING_VERSION = 0.0.7;
+				MTL_FAST_MATH = YES;
+				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SKIP_INSTALL = YES;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
 /* End XCBuildConfiguration section */
 
 /* Begin XCConfigurationList section */
@@ -461,6 +624,15 @@
 			defaultConfigurationIsVisible = 0;
 			defaultConfigurationName = Release;
 		};
+		5FFF7D7827E343FA00B00DA8 /* Build configuration list for PBXNativeTarget "ShareViewController" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				5FFF7D7627E343FA00B00DA8 /* Debug */,
+				5FFF7D7727E343FA00B00DA8 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
 /* End XCConfigurationList section */
 	};
 	rootObject = 504EC2FC1FED79650016851F /* Project object */;

+ 4 - 0
ios/App/App/App.entitlements

@@ -16,5 +16,9 @@
 	<array>
 		<string>iCloud.com.logseq.logseq</string>
 	</array>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.logseq.logseq</string>
+	</array>
 </dict>
 </plist>

+ 4 - 0
ios/App/App/AppDebug.entitlements

@@ -18,5 +18,9 @@
 	<array>
 		<string>iCloud.com.logseq.logseq</string>
 	</array>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.logseq.logseq</string>
+	</array>
 </dict>
 </plist>

+ 39 - 7
ios/App/App/AppDelegate.swift

@@ -1,16 +1,18 @@
 import UIKit
 import Capacitor
+import SendIntent
 
 @UIApplicationMain
 class AppDelegate: UIResponder, UIApplicationDelegate {
 
     var window: UIWindow?
-
+    let store = ShareStore.store
+    
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
         // Override point for customization after application launch.
         return true
     }
-
+    
     func applicationWillResignActive(_ application: UIApplication) {
         // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
         // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
@@ -33,11 +35,41 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
         // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
     }
 
-    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
-        // Called when the app was launched with a url. Feel free to add additional processing here,
-        // but if you want the App API to support tracking app url opens, make sure to keep this call
-        return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
-    }
+    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
+            
+            var success = true
+            if CAPBridge.handleOpenUrl(url, options) {
+                success = ApplicationDelegateProxy.shared.application(app, open: url, options: options)
+            }
+            
+            guard let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true),
+                  let params = components.queryItems else {
+                      return false
+                  }
+            let titles = params.filter { $0.name == "title" }
+            let descriptions = params.filter { $0.name == "description" }
+            let types = params.filter { $0.name == "type" }
+            let urls = params.filter { $0.name == "url" }
+            
+            store.shareItems.removeAll()
+        
+            if(titles.count > 0){
+                for index in 0...titles.count-1 {
+                    var shareItem: JSObject = JSObject()
+                    shareItem["title"] = titles[index].value!
+                    shareItem["description"] = descriptions[index].value!
+                    shareItem["type"] = types[index].value!
+                    shareItem["url"] = urls[index].value!
+                    store.shareItems.append(shareItem)
+                }
+            }
+            
+            store.processed = false
+            let nc = NotificationCenter.default
+            nc.post(name: Notification.Name("triggerSendIntent"), object: nil )
+            
+            return success
+        }
 
     func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
         // Called when the app was launched with an activity, including Universal Links.

+ 0 - 1
ios/App/App/DownloadiCloudFiles.m

@@ -9,6 +9,5 @@
 #import <Capacitor/Capacitor.h>
 
 CAP_PLUGIN(DownloadiCloudFiles, "DownloadiCloudFiles",
-           CAP_PLUGIN_METHOD(iCloudSync, CAPPluginReturnPromise);
            CAP_PLUGIN_METHOD(syncGraph, CAPPluginReturnPromise);
            )

+ 0 - 13
ios/App/App/DownloadiCloudFiles.swift

@@ -50,19 +50,6 @@ public class DownloadiCloudFiles: CAPPlugin,  UIDocumentPickerDelegate  {
         call.resolve(["success": downloaded])
      }
     
-    @objc func iCloudSync(_ call: CAPPluginCall) {
-
-        if let url = self.containerUrl, fileManager.fileExists(atPath: url.path) {
-            do {
-                downloaded = try self.downloadAllFilesFromCloud(at: url, ignorePattern: [".git", ".Trash", "bak", ".recycle"])
-            } catch {
-                print(error.localizedDescription)
-            }
-        }
-        
-        call.resolve(["success": downloaded])
-    }
-    
     func appendUndownloadedFile(at url: URL){
         var lastPathComponent = url.lastPathComponent
         lastPathComponent.removeFirst()

+ 17 - 2
ios/App/App/FsWatcher.swift

@@ -171,14 +171,29 @@ public class PollingWatcher {
         
         if let enumerator = FileManager.default.enumerator(
             at: url,
-            includingPropertiesForKeys: [.isRegularFileKey],
+            includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey],
             // NOTE: icloud downloading requires non-skipsHiddenFiles
             options: [.skipsPackageDescendants]) {
             
             var newMetaDb: [URL: SimpleFileMetadata] = [:]
             
             for case let fileURL as URL in enumerator {
-                if !fileURL.isSkipped() {
+                guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]),
+                      let isDirectory = resourceValues.isDirectory,
+                      let isRegularFile = resourceValues.isRegularFile,
+                      let name = resourceValues.name
+                else {
+                    continue
+                }
+                
+                if isDirectory {
+                    // NOTE: URL.path won't end with a `/`
+                    if fileURL.path.hasSuffix("/logseq/bak") || name == ".recycle" || name.hasPrefix(".") || name == "node_modules" {
+                        enumerator.skipDescendants()
+                    }
+                }
+            
+                if isRegularFile && !fileURL.isSkipped() {
                     if let meta = SimpleFileMetadata(of: fileURL) {
                         newMetaDb[fileURL] = meta
                     }

+ 13 - 0
ios/App/App/Info.plist

@@ -29,6 +29,19 @@
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
 	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleTypeRole</key>
+			<string>Viewer</string>
+			<key>CFBundleURLName</key>
+			<string>com.logseq.logseq</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>logseq</string>
+			</array>
+		</dict>
+	</array>
 	<key>CFBundleVersion</key>
 	<string>$(CURRENT_PROJECT_VERSION)</string>
 	<key>LSApplicationCategoryType</key>

+ 1 - 0
ios/App/Podfile

@@ -15,6 +15,7 @@ def capacitor_pods
   pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
   pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
   pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
+  pod 'SendIntent', :path => '../../node_modules/send-intent'
 end
 
 target 'Logseq' do

+ 24 - 0
ios/App/ShareViewController/Base.lproj/MainInterface.storyboard

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
+    <dependencies>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
+        <capability name="Safe area layout guides" minToolsVersion="9.0"/>
+        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+    </dependencies>
+    <scenes>
+        <!--Share View Controller-->
+        <scene sceneID="ceB-am-kn3">
+            <objects>
+                <viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
+                    <view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
+                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
+                        <viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+        </scene>
+    </scenes>
+</document>

+ 33 - 0
ios/App/ShareViewController/Info.plist

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>NSExtension</key>
+	<dict>
+		<key>NSExtensionAttributes</key>
+		<dict>
+			<key>NSExtensionActivationRule</key>
+			<dict>
+				<key>NSExtensionActivationSupportsFileWithMaxCount</key>
+				<integer>5</integer>
+				<key>NSExtensionActivationSupportsImageWithMaxCount</key>
+				<integer>5</integer>
+				<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
+				<integer>5</integer>
+				<key>NSExtensionActivationSupportsText</key>
+				<true/>
+				<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
+				<integer>1</integer>
+				<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
+				<integer>1</integer>
+				<key>NSExtensionActivationUsesStrictMatching</key>
+				<false/>
+			</dict>
+		</dict>
+		<key>NSExtensionMainStoryboard</key>
+		<string>MainInterface</string>
+		<key>NSExtensionPointIdentifier</key>
+		<string>com.apple.share-services</string>
+	</dict>
+</dict>
+</plist>

+ 10 - 0
ios/App/ShareViewController/ShareViewController.entitlements

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.logseq.logseq</string>
+	</array>
+</dict>
+</plist>

+ 201 - 0
ios/App/ShareViewController/ShareViewController.swift

@@ -0,0 +1,201 @@
+//
+//  ShareViewController.swift
+//  ShareViewController
+//
+//  Created by leizhe on 2022/3/17.
+//
+
+
+import MobileCoreServices
+import Social
+import UIKit
+
+class ShareItem {
+    public var title: String?
+    public var type: String?
+    public var url: String?
+}
+
+class ShareViewController: UIViewController {
+    
+    private var shareItems: [ShareItem] = []
+    
+    var groupContainerUrl: URL? {
+        return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.logseq.logseq")
+    }
+    
+    override public func viewDidAppear(_ animated: Bool) {
+       super.viewDidAppear(animated)
+       self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
+    }
+    
+    private func sendData() {
+        let queryItems = shareItems.map {
+            [
+                URLQueryItem(
+                    name: "title",
+                    value: $0.title?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
+                URLQueryItem(name: "description", value: ""),
+                URLQueryItem(
+                    name: "type",
+                    value: $0.type?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
+                URLQueryItem(
+                    name: "url",
+                    value: $0.url?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
+            ]
+        }.flatMap({ $0 })
+        var urlComps = URLComponents(string: "logseq://")!
+        urlComps.queryItems = queryItems
+        openURL(urlComps.url!)
+    }
+    
+    fileprivate func createSharedFileUrl(_ url: URL?) -> String {
+        
+        let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + "/" + url!
+            .lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
+        try? Data(contentsOf: url!).write(to: URL(string: copyFileUrl)!)
+        
+        return copyFileUrl
+    }
+    
+    func saveScreenshot(_ image: UIImage) -> String {
+        
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss"
+        
+        let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
+        + dateFormatter.string(from: Date()) + ".png"
+        
+        do {
+            try image.pngData()?.write(to: URL(string: copyFileUrl)!)
+            return copyFileUrl
+        } catch {
+            print(error.localizedDescription)
+            return ""
+        }
+    }
+    
+    fileprivate func handleTypeUrl(_ attachment: NSItemProvider)
+    async throws -> ShareItem
+    {
+        let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil)
+        let url = results as! URL?
+        let shareItem: ShareItem = ShareItem()
+        
+        if url!.isFileURL {
+            shareItem.title = url!.lastPathComponent
+            shareItem.type = "application/" + url!.pathExtension.lowercased()
+            shareItem.url = createSharedFileUrl(url)
+        } else {
+            shareItem.title = url!.absoluteString
+            shareItem.url = url!.absoluteString
+            shareItem.type = "text/plain"
+        }
+        
+        return shareItem
+    }
+    
+    fileprivate func handleTypeText(_ attachment: NSItemProvider)
+    async throws -> ShareItem
+    {
+        let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil)
+        let shareItem: ShareItem = ShareItem()
+        let text = results as! String
+        shareItem.title = text
+        shareItem.type = "text/plain"
+        
+        return shareItem
+    }
+    
+    fileprivate func handleTypeMovie(_ attachment: NSItemProvider)
+    async throws -> ShareItem
+    {
+        let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil)
+        let shareItem: ShareItem = ShareItem()
+        
+        let url = results as! URL?
+        shareItem.title = url!.lastPathComponent
+        shareItem.type = "video/" + url!.pathExtension.lowercased()
+        shareItem.url = createSharedFileUrl(url)
+        
+        return shareItem
+    }
+    
+    fileprivate func handleTypeImage(_ attachment: NSItemProvider)
+    async throws -> ShareItem
+    {
+        let data = try await attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil)
+        
+        let shareItem: ShareItem = ShareItem()
+        switch data {
+        case let image as UIImage:
+            shareItem.title = "screenshot"
+            shareItem.type = "image/png"
+            shareItem.url = self.saveScreenshot(image)
+        case let url as URL:
+            shareItem.title = url.lastPathComponent
+            shareItem.type = "image/" + url.pathExtension.lowercased()
+            shareItem.url = self.createSharedFileUrl(url)
+        default:
+            print("Unexpected image data:", type(of: data))
+        }
+        
+        return shareItem
+    }
+    
+    
+    override public func viewDidLoad() {
+        super.viewDidLoad()
+        
+        shareItems.removeAll()
+        
+        let extensionItem = extensionContext?.inputItems.first as! NSExtensionItem
+        Task {
+            try await withThrowingTaskGroup(
+                of: ShareItem.self,
+                body: { taskGroup in
+                    
+                    for attachment in extensionItem.attachments! {
+                        if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
+                            taskGroup.addTask {
+                                return try await self.handleTypeUrl(attachment)
+                            }
+                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
+                            taskGroup.addTask {
+                                return try await self.handleTypeText(attachment)
+                            }
+                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
+                            taskGroup.addTask {
+                                return try await self.handleTypeMovie(attachment)
+                            }
+                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
+                            taskGroup.addTask {
+                                return try await self.handleTypeImage(attachment)
+                            }
+                        }
+                    }
+                    
+                    for try await item in taskGroup {
+                        self.shareItems.append(item)
+                    }
+                })
+            
+            self.sendData()
+            
+        }
+    }
+    
+    @discardableResult
+    @objc func openURL(_ url: URL) -> Bool {
+        var responder: UIResponder? = self
+        while responder != nil {
+            if let application = responder as? UIApplication {
+                return application.perform(#selector(openURL(_:)), with: url) != nil
+            }
+            responder = responder?.next
+        }
+        return false
+    }
+    
+}
+

+ 9 - 0
libs/src/LSPlugin.core.ts

@@ -201,16 +201,19 @@ enum PluginLocalLoadStatus {
 function initUserSettingsHandlers (pluginLocal: PluginLocal) {
   const _ = (label: string): any => `settings:${label}`
 
+  // settings:schema
   pluginLocal.on(_('schema'), ({ schema, isSync }: { schema: Array<SettingSchemaDesc>, isSync?: boolean }) => {
     pluginLocal.settingsSchema = schema
     pluginLocal.settings?.setSchema(schema, isSync)
   })
 
+  // settings:update
   pluginLocal.on(_('update'), (attrs) => {
     if (!attrs) return
     pluginLocal.settings?.set(attrs)
   })
 
+  // settings:visible:changed
   pluginLocal.on(_('visible:changed'), (payload) => {
     const visible = payload?.visible
     invokeHostExportedApi('set_focused_settings',
@@ -221,6 +224,7 @@ function initUserSettingsHandlers (pluginLocal: PluginLocal) {
 function initMainUIHandlers (pluginLocal: PluginLocal) {
   const _ = (label: string): any => `main-ui:${label}`
 
+  // main-ui:visible
   pluginLocal.on(_('visible'), ({ visible, toggle, cursor, autoFocus }) => {
     const el = pluginLocal.getMainUIContainer()
     el?.classList[toggle ? 'toggle' : (visible ? 'add' : 'remove')]('visible')
@@ -237,6 +241,7 @@ function initMainUIHandlers (pluginLocal: PluginLocal) {
     }
   })
 
+  // main-ui:attrs
   pluginLocal.on(_('attrs'), (attrs: Partial<UIContainerAttrs>) => {
     const el = pluginLocal.getMainUIContainer()
     Object.entries(attrs).forEach(([k, v]) => {
@@ -258,6 +263,7 @@ function initMainUIHandlers (pluginLocal: PluginLocal) {
     })
   })
 
+  // main-ui:style
   pluginLocal.on(_('style'), (style: Record<string, any>) => {
     const el = pluginLocal.getMainUIContainer()
     const isInitedLayout = !!el.dataset.inited_layout
@@ -278,6 +284,7 @@ function initProviderHandlers (pluginLocal: PluginLocal) {
   let _ = (label: string): any => `provider:${label}`
   let themed = false
 
+  // provider:theme
   pluginLocal.on(_('theme'), (theme: ThemeOptions) => {
     pluginLocal.themeMgr.registerTheme(
       pluginLocal.id,
@@ -293,6 +300,7 @@ function initProviderHandlers (pluginLocal: PluginLocal) {
     }
   })
 
+  // provider:style
   pluginLocal.on(_('style'), (style: StyleString | StyleOptions) => {
     let key: string | undefined
 
@@ -311,6 +319,7 @@ function initProviderHandlers (pluginLocal: PluginLocal) {
     )
   })
 
+  // provider:ui
   pluginLocal.on(_('ui'), (ui: UIOptions) => {
     pluginLocal._onHostMounted(() => {
 

+ 4 - 4
libs/yarn.lock

@@ -682,7 +682,7 @@ locate-path@^5.0.0:
   dependencies:
     p-locate "^4.1.0"
 
-lodash-es@^4.17.21:
[email protected]:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
   integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
@@ -750,9 +750,9 @@ mimic-fn@^2.1.0:
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
 minimist@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
-  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
 [email protected]:
   version "2.1.2"

+ 7 - 7
package.json

@@ -5,7 +5,7 @@
     "main": "static/electron.js",
     "devDependencies": {
         "@capacitor/cli": "3.2.2",
-        "@playwright/test": "^1.17.1",
+        "@playwright/test": "^1.19.2",
         "@tailwindcss/ui": "0.7.2",
         "@types/gulp": "^4.0.7",
         "cross-env": "^7.0.3",
@@ -14,7 +14,7 @@
         "gulp": "^4.0.2",
         "gulp-clean-css": "^4.3.0",
         "npm-run-all": "^4.1.5",
-        "playwright": "^1.17.1",
+        "playwright": "^1.19.2",
         "postcss": "8.2.13",
         "postcss-cli": "8.3.1",
         "postcss-import": "^14.0.0",
@@ -50,7 +50,7 @@
         "css:watch": "cross-env TAILWIND_MODE=watch postcss tailwind.all.css -o static/css/style.css --verbose --watch",
         "cljs:watch": "clojure -M:cljs watch app electron",
         "cljs:app-watch": "clojure -M:cljs watch app",
-        "cljs:electron-watch": "clojure -M:cljs watch app electron",
+        "cljs:electron-watch": "clojure -M:cljs watch app electron --config-merge '{:asset-path \"./js\"}'",
         "cljs:release": "clojure -M:cljs release app publishing electron",
         "cljs:release-electron": "clojure -M:cljs release app publishing electron --debug",
         "cljs:release-app": "clojure -M:cljs release app",
@@ -75,9 +75,9 @@
         "@excalidraw/excalidraw": "0.10.0",
         "@kanru/rage-wasm": "0.2.1",
         "@logseq/react-tweet-embed": "1.3.1-1",
-        "@sentry/browser": "6.4.1",
-        "@sentry/electron": "2.5.1",
-        "@tabler/icons": "1.41.2",
+        "@sentry/react": "^6.18.2",
+        "@sentry/tracing": "^6.18.2",
+        "@tabler/icons": "1.54.0",
         "@tippyjs/react": "4.2.5",
         "chokidar": "3.5.1",
         "chrono-node": "2.2.4",
@@ -112,12 +112,12 @@
         "react-transition-group": "4.3.0",
         "reakit": "0.11.1",
         "remove-accents": "0.4.2",
+        "send-intent": "3.0.11",
         "threads": "1.6.5",
         "url": "^0.11.0",
         "yargs-parser": "20.2.4"
     },
     "resolutions": {
-        "@playwright/test/colors": "1.4.0",
         "pixi-graph-fork/@pixi/app": "6.2.0",
         "pixi-graph-fork/@pixi/constants": "6.2.0",
         "pixi-graph-fork/@pixi/core": "6.2.0",

+ 9 - 3
resources/css/common.css

@@ -92,15 +92,19 @@ html[data-theme='dark'] {
   --color-level-6: #3a7e8e;
 }
 
+/* You should always use .light-theme for light mode, the .white-theme is just for backword compatibility.
+
+See: https://github.com/logseq/logseq/pull/4652. */
 .white-theme,
+.light-theme,
 html[data-theme='light'] {
   --ls-primary-background-color: #ffffff;
   --ls-secondary-background-color: #f7f7f7;
   --ls-tertiary-background-color: #f1eee8;
   --ls-quaternary-background-color: #e8e5de;
   --ls-table-tr-even-background-color: #f7f7f7;
-  --ls-active-primary-color: rgb(4, 85, 145);
-  --ls-active-secondary-color: #003761;
+  --ls-active-primary-color: rgb(0, 105, 182);
+  --ls-active-secondary-color: #00477c;
   --ls-block-properties-background-color: #f7f7f7;
   --ls-page-properties-background-color: #f7f7f7;
   --ls-block-ref-link-text-color: #d8e1e8;
@@ -737,6 +741,7 @@ li p:last-child,
 }
 
 .canceled,
+.cancelled,
 .done {
   text-decoration: line-through;
   opacity: 0.6;
@@ -776,7 +781,8 @@ li p:last-child,
 }
 
 .done,
-.canceled {
+.canceled,
+.cancelled {
   opacity: 0.7;
 }
 

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 3
resources/css/tabler-icons.min.css


BIN
resources/fonts/tabler-icons.eot


BIN
resources/fonts/tabler-icons.woff


BIN
resources/fonts/tabler-icons.woff2


+ 7 - 0
resources/forge.config.js

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

BIN
resources/img/file-edn.png


BIN
resources/img/folder-logo.png


BIN
resources/img/folder.png


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
resources/js/isomorphic-git/1.7.4/index.umd.min.js


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
resources/js/magic_portal.js


+ 6 - 2
resources/package.json

@@ -1,6 +1,6 @@
 {
   "name": "Logseq",
-  "version": "0.6.1",
+  "version": "0.6.5",
   "main": "electron.js",
   "author": "Logseq",
   "license": "AGPL-3.0",
@@ -32,7 +32,11 @@
     "semver": "7.3.5",
     "update-electron-app": "2.0.1",
     "extract-zip": "2.0.1",
-    "https-proxy-agent": "5.0.0"
+    "diff-match-patch": "1.0.5",
+    "https-proxy-agent": "5.0.0",
+    "@sentry/electron": "2.5.1",
+    "posthog-js": "1.10.2",
+    "@andelf/rsapi": "0.0.5"
   },
   "devDependencies": {
     "@electron-forge/cli": "^6.0.0-beta.57",

+ 52 - 0
scripts/lint_rules.clj

@@ -0,0 +1,52 @@
+#!/usr/bin/env bb
+
+(require '[babashka.deps :as deps])
+(deps/add-deps '{:deps {me.tagaholic/dlint {:mvn/version "0.1.0"}
+                        io.lambdaforge/datalog-parser {:mvn/version "0.1.11"}}
+                 :paths ["src/main"]})
+
+(ns lint-rules
+  "Lint datalog rules for parse-ability and unbound variables"
+  (:require [datalog.parser.impl :as parser-impl]
+            [dlint.core :as dlint]
+            [frontend.db.rules :as rules]))
+
+(defn- lint-unbound-rule [rule]
+  (->> (dlint/lint [rule])
+       (keep
+        (fn [[k v]]
+          (when (seq v)
+            {:success false :name k :rule rule :unbound-vars v})))))
+
+(defn- lint-rule [rule]
+  (try (parser-impl/parse-rule rule)
+    {:success true :rule rule}
+    (catch Exception e
+      {:success false :rule rule :error (.getMessage e)})))
+
+(defn- collect-logseq-rules
+  "Collects logseq rules and prepares them for linting"
+  []
+  (into rules/rules
+        (-> rules/query-dsl-rules
+            ;; TODO: Update linter to handle false positive on ?str-val
+            (dissoc :property)
+            vals)))
+
+(defn -main [rules]
+  (let [invalid-unbound-rules (->> rules
+                                   (mapcat lint-unbound-rule)
+                                   (remove :success))
+        invalid-rules (->> rules
+                           (map lint-rule)
+                           (remove :success))
+        lint-results (concat invalid-unbound-rules invalid-rules)]
+    (if (seq lint-results)
+      (do
+        (println (count lint-results) "rules failed to lint:")
+        (println lint-results)
+        (System/exit 1))
+      (println (count rules) "datalog rules linted fine!"))))
+
+(when (= *file* (System/getProperty "babashka.file"))
+  (-main (collect-logseq-rules)))

+ 1 - 1
scripts/src/logseq/tasks/dev.clj

@@ -6,7 +6,7 @@
 (defn watch
   "Watches environment to reload cljs, css and other assets"
   []
-  (shell "yarn watch"))
+  (shell "yarn electron-watch"))
 
 (defn- file-modified-later-than?
   [file comparison-instant]

+ 11 - 41
scripts/src/logseq/tasks/lang.clj

@@ -1,58 +1,28 @@
 (ns logseq.tasks.lang
   "Tasks related to language translations"
-  (:require [logseq.tasks.rewrite-clj :as rewrite-clj]
-            [clojure.set :as set]
+  (:require [clojure.set :as set]
+            [frontend.dicts :as dicts]
+            [frontend.modules.shortcut.dicts :as shortcut-dicts]
             [logseq.tasks.util :as task-util]))
 
 (defn- get-dicts
   []
-  (dissoc (rewrite-clj/metadata-var-sexp "src/main/frontend/dicts.cljs" "dicts")
-          :tongue/fallback))
+  (dissoc dicts/dicts :tongue/fallback))
 
-(defn- get-non-en-shortcuts
+(defn- get-all-dicts
   []
-  (nth (rewrite-clj/metadata-var-sexp "src/main/frontend/modules/shortcut/dict.cljs"
-                                      "dict")
-       3))
-
-;; unnecessary complexity :(
-(defn- decorate-namespace [k]
-  (let [n (name k)
-        ns (namespace k)]
-    (keyword (str "command." ns) n)))
-
-(defn- get-en-shortcut-dicts
-  []
-  (->> (rewrite-clj/metadata-var-sexp
-        "src/main/frontend/modules/shortcut/config.cljs"
-        "all-default-keyboard-shortcuts")
-       (map (fn [[k v]] (vector (decorate-namespace k) (:desc v))))
-       (into {})))
-
-(defn- get-en-categories
-  []
-  (->> (rewrite-clj/metadata-var-sexp
-        "src/main/frontend/modules/shortcut/config.cljs"
-        "category")
-       (map (fn [[k v]] (vector k (:doc (meta v)))))
-       (into {})))
-
-(defn- get-shortcuts
-  []
-  (merge {:en (merge (get-en-categories)
-                     (get-en-shortcut-dicts))}
-         (get-non-en-shortcuts)))
+  (merge-with merge (get-dicts) shortcut-dicts/dicts))
 
 (defn- get-languages
   []
-  (->> (rewrite-clj/var-sexp "src/main/frontend/dicts.cljs" "languages")
+  (->> dicts/languages
        (map (juxt :value :label))
        (into {})))
 
 (defn list-langs
   "List translated langagues with their number of translations"
   []
-  (let [dicts (merge-with merge (get-dicts) (get-shortcuts))
+  (let [dicts (get-all-dicts)
         en-count (count (dicts :en))
         langs (get-languages)]
     (->> dicts
@@ -79,7 +49,7 @@
             (println "Language" lang "does not have an entry in dicts.cljs")
             (System/exit 1))
         all-dicts [[(get-dicts) "frontend/dicts.cljs"]
-                   [(get-shortcuts) "shortcut/dict.cljs"]]
+                   [shortcut-dicts/dicts "shortcut/dicts.cljs"]]
         all-missing (map (fn [[dicts file]]
                            [(select-keys (dicts :en)
                                          (set/difference (set (keys (dicts :en)))
@@ -102,7 +72,7 @@
 (defn invalid-translations
   "Lists translation that don't exist in English"
   []
-  (let [dicts (merge-with merge (get-dicts) (get-shortcuts))
+  (let [dicts (get-all-dicts)
         ;; For now defined as :en but clj-kondo analysis could be more thorough
         valid-keys (set (keys (dicts :en)))
         invalid-dicts
@@ -122,7 +92,7 @@
 (defn list-duplicates
   "Lists translations that are the same as the one in English"
   [& args]
-  (let [dicts (merge-with merge (get-dicts) (get-shortcuts))
+  (let [dicts (get-all-dicts)
         en-dicts (dicts :en)
         lang (or (keyword (first args))
                  (task-util/print-usage "LOCALE"))

+ 0 - 27
scripts/src/logseq/tasks/rewrite_clj.clj

@@ -1,27 +0,0 @@
-(ns logseq.tasks.rewrite-clj
-  "Rewrite-clj fns"
-  (:require [rewrite-clj.zip :as z]))
-
-(defn- find-symbol-value-sexpr
-  ([zloc sym] (find-symbol-value-sexpr zloc sym z/right))
-  ([zloc sym nav-fn]
-   ;; Returns first symbol found
-   (-> (z/find-value zloc z/next sym)
-       nav-fn
-       z/sexpr)))
-
-(defn var-sexp
-  "Returns value sexp to the right of var"
-  [file string-var]
-  (let [zloc (z/of-string (slurp file))
-        sexp (find-symbol-value-sexpr zloc (symbol string-var))]
-    (or sexp
-        (throw (ex-info "var-sexp must not return nil" {:file file :string-var string-var})))))
-
-(defn metadata-var-sexp
-  "Returns value sexp to the right of var with metadata"
-  [file string-var]
-  (let [zloc (z/of-string (slurp file))
-        sexp (find-symbol-value-sexpr zloc (symbol string-var) (comp z/right z/up))]
-    (or sexp
-        (throw (ex-info "sexp must not return nil" {:file file :string-var string-var})))))

+ 19 - 0
scripts/src/logseq/tasks/spec.clj

@@ -0,0 +1,19 @@
+(ns logseq.tasks.spec
+  "Clojure spec related tasks"
+  (:require [clojure.spec.alpha :as s]
+            [cheshire.core :as json]
+            [frontend.spec.storage :as storage-spec]
+            [clojure.edn :as edn]))
+
+;; To create file for validation, `JSON.stringify(localStorage)` in the js
+;; console and copy string to file
+(defn validate-local-storage
+  "Validate a localStorage json file"
+  [file]
+  (let [local-storage
+        (update-vals (json/parse-string (slurp file) keyword)
+                     ;; Not all localStorage values are edn so gracefully return.
+                     ;; For example, logseq-plugin-tabs stores data as json
+                     #(try (edn/read-string %) (catch Throwable _ %)))]
+    (s/assert ::storage-spec/local-storage local-storage)
+    (println "Success!")))

+ 5 - 3
shadow-cljs.edn

@@ -36,11 +36,13 @@
         :closure-defines  {goog.debug.LOGGING_ENABLED      true
                            frontend.config/GITHUB_APP_NAME #shadow/env "GITHUB_APP2_NAME"}
 
-        :dev      {:asset-path "/static/js"}
+        ;; NOTE: electron, browser/mobile-app use different asset-paths.
+        ;;   For browser/mobile-app devs, assets are located in /static/js(via HTTP root).
+        ;;   For electron devs, assets are located in ./js(via relative path).
+        ; :dev      {:asset-path "./js"}
         :devtools {:before-load frontend.core/stop  ;; before live-reloading any code call this function
                    :after-load  frontend.core/start ;; after live-reloading finishes call this function
                    :watch-path  "/static"
-                   :watch-dir   "static"
                    :preloads    [devtools.preload
                                  shadow.remote.runtime.cljs.browser]}}
 
@@ -64,7 +66,7 @@
          :devtools        {:enabled false}
          ;; disable :static-fns to allow for with-redefs and repl development
          :compiler-options {:static-fns false}
-         :main            frontend.node-test-runner/main}
+         :main            frontend.test.node-test-runner/main}
 
   :publishing {:target        :browser
                :module-loader true

+ 56 - 34
src/electron/electron/core.cljs

@@ -2,7 +2,7 @@
   (:require [electron.handler :as handler]
             [electron.search :as search]
             [electron.updater :refer [init-updater]]
-            [electron.utils :refer [*win mac? linux? logger get-win-from-sender restore-user-fetch-agent]]
+            [electron.utils :refer [*win mac? linux? logger get-win-from-sender restore-user-fetch-agent send-to-renderer]]
             [clojure.string :as string]
             [promesa.core :as p]
             [cljs-bean.core :as bean]
@@ -18,8 +18,9 @@
             [electron.exceptions :as exceptions]
             ["/electron/utils" :as utils]))
 
-(defonce LSP_SCHEME "lsp")
-(defonce LSP_PROTOCOL (str LSP_SCHEME "://"))
+(defonce LSP_SCHEME "logseq")
+(defonce FILE_LSP_SCHEME "lsp")
+(defonce LSP_PROTOCOL (str FILE_LSP_SCHEME "://"))
 (defonce PLUGIN_URL (str LSP_PROTOCOL "logseq.io/"))
 (defonce STATIC_URL (str LSP_PROTOCOL "logseq.com/"))
 (defonce PLUGINS_ROOT (.join path (.homedir os) ".logseq/plugins"))
@@ -38,32 +39,44 @@
                    :logger logger
                    :win    win})))
 
-(defn setup-interceptor! []
-  (.registerFileProtocol
-    protocol "assets"
-    (fn [^js request callback]
-      (let [url (.-url request)
-            path (string/replace url "assets://" "")
-            path (js/decodeURIComponent path)]
-        (callback #js {:path path}))))
+(defn setup-interceptor! [^js app]
+  (.setAsDefaultProtocolClient app LSP_SCHEME)
 
   (.registerFileProtocol
-    protocol LSP_SCHEME
-    (fn [^js request callback]
-      (let [url (.-url request)
-            url' ^js (js/URL. url)
-            [_ ROOT] (if (string/starts-with? url PLUGIN_URL)
-                         [PLUGIN_URL PLUGINS_ROOT]
-                         [STATIC_URL js/__dirname])
-
-            path' (.-pathname url')
-            path' (js/decodeURIComponent path')
-            path' (.join path ROOT path')]
+   protocol "assets"
+   (fn [^js request callback]
+     (let [url (.-url request)
+           path (string/replace url "assets://" "")
+           path (js/decodeURIComponent path)]
+       (callback #js {:path path}))))
 
-        (callback #js {:path path'}))))
+  (.registerFileProtocol
+   protocol FILE_LSP_SCHEME
+   (fn [^js request callback]
+     (let [url (.-url request)
+           url' ^js (js/URL. url)
+           [_ ROOT] (if (string/starts-with? url PLUGIN_URL)
+                      [PLUGIN_URL PLUGINS_ROOT]
+                      [STATIC_URL js/__dirname])
+
+           path' (.-pathname url')
+           path' (js/decodeURIComponent path')
+           path' (.join path ROOT path')]
+
+       (callback #js {:path path'}))))
+
+  (.on app "open-url"
+       (fn [event url]
+         (.info logger "open-url" (str {:url url
+                                        :event event}))
+
+         (let [parsed-url (js/URL. url)]
+           (when (and (= (str LSP_SCHEME ":") (.-protocol parsed-url))
+                      (= "auth-callback" (.-host parsed-url)))
+             (send-to-renderer "loginCallback" (.get (.-searchParams parsed-url) "code"))))))
 
   #(do
-     (.unregisterProtocol protocol LSP_SCHEME)
+     (.unregisterProtocol protocol FILE_LSP_SCHEME)
      (.unregisterProtocol protocol "assets")))
 
 (defn- handle-export-publish-assets [_event html custom-css-path repo-path asset-filenames output-path]
@@ -170,14 +183,15 @@
     (do
       (search/close!)
       (.quit app))
-    (do
+    (let [privileges {:standard        true
+                      :secure          true
+                      :bypassCSP       true
+                      :supportFetchAPI true}]
       (.registerSchemesAsPrivileged
-       protocol (bean/->js [{:scheme     LSP_SCHEME
-                             :privileges {:standard        true
-                                          :secure          true
-                                          :bypassCSP       true
-                                          :supportFetchAPI true}}]))
-
+        protocol (bean/->js [{:scheme     LSP_SCHEME
+                              :privileges privileges}
+                             {:scheme     FILE_LSP_SCHEME
+                              :privileges privileges}]))
       (.on app "second-instance"
            (fn [_event _commandLine _workingDirectory]
              (when-let [win @*win]
@@ -194,7 +208,7 @@
                                      (.quit app)))
       (.on app "ready"
            (fn []
-             (let [t0 (setup-interceptor!)
+             (let [t0 (setup-interceptor! app)
                    ^js win (win/create-main-window)
                    _ (reset! *win win)]
                (.. logger (info (str "Logseq App(" (.getVersion app) ") Starting... ")))
@@ -224,18 +238,26 @@
                (@*setup-fn)
 
                ;; main window events
+               ;; TODO merge with window/on-close-actions!
+               ;; TODO elimilate the difference between main and non-main windows
                (.on win "close" (fn [e]
-                                  (when @*quit-dirty?
+                                  (when @*quit-dirty? ;; when not updating
                                     (.preventDefault e)
-                                    (state/close-window! win)
                                     (let [web-contents (. win -webContents)]
                                       (.send web-contents "persistent-dbs"))
                                     (async/go
                                       (let [_ (async/<! state/persistent-dbs-chan)]
                                         (if (or @win/*quitting? (not mac?))
+                                          ;; only cmd+q quitting will trigger actual closing on mac
+                                          ;; otherwise, it's just hiding - don't do any actuall closing in that case
+                                          ;; except saving transit
                                           (when-let [win @*win]
+                                            (when-let [dir (state/get-window-graph-path win)]
+                                              (handler/close-watcher-when-orphaned! win dir))
+                                            (state/close-window! win)
                                             (win/destroy-window! win)
                                             (reset! *win nil))
+                                          ;; Just hiding - don't do any actuall closing operation
                                           (do (.preventDefault ^js/Event e)
                                               (if (and mac? (.isFullScreen win))
                                                 (do (.once win "leave-full-screen" #(.hide win))

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

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

+ 43 - 20
src/electron/electron/fs_watcher.cljs

@@ -4,31 +4,41 @@
             ["chokidar" :as watcher]
             [electron.utils :as utils]
             ["electron" :refer [app]]
-            [frontend.util.fs :as util-fs]))
+            [frontend.util.fs :as util-fs]
+            [electron.window :as window]))
 
 ;; TODO: explore different solutions for different platforms
 ;; 1. https://github.com/Axosoft/nsfw
 
 (defonce polling-interval 10000)
-(defonce file-watcher (atom nil))
+;; dir -> Watcher
+(defonce *file-watcher (atom {})) ;; val: [watcher watcher-del-f]
 
 (defonce file-watcher-chan "file-watcher")
-(defn send-file-watcher! [^js win type payload]
-  (when-not (.isDestroyed win)
-    (.. win -webContents
-        (send file-watcher-chan
-              (bean/->js {:type type :payload payload})))))
+(defn- send-file-watcher! [dir type payload]
+  ;; Should only send to one window; then dbsync will do his job
+  ;; If no window is on this graph, just ignore
+  (let [sent? (some (fn [^js win]
+                      (when-not (.isDestroyed win)
+                        (.. win -webContents
+                            (send file-watcher-chan
+                                  (bean/->js {:type type :payload payload})))
+                        true)) ;; break some loop on success
+                    (window/get-graph-all-windows dir))]
+    (when-not sent? (prn "unhandled file event will cause uncatched file modifications!.
+                          target:" dir))))
 
 (defn- publish-file-event!
-  [win dir path event]
-  (send-file-watcher! win event {:dir (utils/fix-win-path! dir)
+  [dir path event]
+  (send-file-watcher! dir event {:dir (utils/fix-win-path! dir)
                                  :path (utils/fix-win-path! path)
                                  :content (utils/read-file path)
                                  :stat (fs/statSync path)}))
 
 (defn watch-dir!
-  [win dir]
-  (when (fs/existsSync dir)
+  [_win dir]
+  (when (and (fs/existsSync dir)
+             (not (get @*file-watcher dir)))
     (let [watcher (.watch watcher dir
                           (clj->js
                            {:ignored (fn [path]
@@ -40,18 +50,19 @@
                             :persistent true
                             :disableGlobbing true
                             :usePolling false
-                            :awaitWriteFinish true}))]
-      (reset! file-watcher watcher)
+                            :awaitWriteFinish true}))
+          watcher-del-f #(.close watcher)]
+      (swap! *file-watcher assoc dir [watcher watcher-del-f])
       ;; TODO: batch sender
       (.on watcher "add"
            (fn [path]
-             (publish-file-event! win dir path "add")))
+             (publish-file-event! dir path "add")))
       (.on watcher "change"
            (fn [path]
-             (publish-file-event! win dir path "change")))
+             (publish-file-event! dir path "change")))
       (.on watcher "unlink"
            (fn [path]
-             (send-file-watcher! win "unlink"
+             (send-file-watcher! dir "unlink"
                                  {:dir (utils/fix-win-path! dir)
                                   :path (utils/fix-win-path! path)})))
       (.on watcher "error"
@@ -59,11 +70,23 @@
              (println "Watch error happened: "
                       {:path path})))
 
-      (.on app "quit" #(.close watcher))
+      ;; electron app extends `EventEmitter`
+      ;; TODO check: duplicated with the logic in "window-all-closed" ?
+      (.on app "quit" watcher-del-f)
 
       true)))
 
 (defn close-watcher!
-  []
-  (when-let [watcher @file-watcher]
-    (.close watcher)))
+  "If no `dir` provided, close all watchers;
+   Otherwise, close the specific watcher if exists"
+  ([]
+   (doseq [[watcher watcher-del-f] (vals @*file-watcher)]
+     (.close watcher)
+     (.removeListener app "quit" watcher-del-f))
+   (reset! *file-watcher {}))
+  ([dir]
+   (let [[watcher watcher-del-f] (get @*file-watcher dir)]
+     (when watcher
+       (.close watcher)
+       (.removeListener app "quit" watcher-del-f)
+       (swap! *file-watcher dissoc dir)))))

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

@@ -51,7 +51,7 @@
           p (.join path graph-path ".git")]
       (when (and (fs/existsSync p)
                  (.isFile (fs/statSync p)))
-        (let [content (.toString (fs/readFileSync p))
+        (let [content (string/trim (.toString (fs/readFileSync p)))
               dir-path (string/replace content "gitdir: " "")]
           (when (and content
                      (string/starts-with? content "gitdir:")

+ 153 - 64
src/electron/electron/handler.cljs

@@ -1,11 +1,12 @@
 (ns electron.handler
-  (:require ["electron" :refer [ipcMain dialog app autoUpdater]]
+  (:require ["electron" :refer [ipcMain dialog app autoUpdater shell]]
             [cljs-bean.core :as bean]
             ["fs" :as fs]
             ["buffer" :as buffer]
             ["fs-extra" :as fs-extra]
             ["path" :as path]
             ["os" :as os]
+            ["diff-match-patch" :as google-diff]
             [electron.fs-watcher :as watcher]
             [electron.configs :as cfgs]
             [promesa.core :as p]
@@ -16,7 +17,8 @@
             [electron.search :as search]
             [electron.git :as git]
             [electron.plugin :as plugin]
-            [electron.window :as win]))
+            [electron.window :as win]
+            [electron.file-sync-rsapi :as rsapi]))
 
 (defmulti handle (fn [_window args] (keyword (first args))))
 
@@ -30,15 +32,15 @@
 (defn- readdir
   [dir]
   (->> (tree-seq
-         (fn [^js f]
-           (.isDirectory (fs/statSync f) ()))
-         (fn [d]
-           (let [files (fs/readdirSync d (clj->js {:withFileTypes true}))]
-             (->> files
-                  (remove #(.isSymbolicLink ^js %))
-                  (remove #(string/starts-with? (.-name ^js %) "."))
-                  (map #(.join path d (.-name %))))))
-         dir)
+        (fn [^js f]
+          (.isDirectory (fs/statSync f) ()))
+        (fn [d]
+          (let [files (fs/readdirSync d (clj->js {:withFileTypes true}))]
+            (->> files
+                 (remove #(.isSymbolicLink ^js %))
+                 (remove #(string/starts-with? (.-name ^js %) "."))
+                 (map #(.join path d (.-name %))))))
+        dir)
        (doall)
        (vec)))
 
@@ -49,28 +51,59 @@
   (if (plugin/dotdir-file? path)
     (fs/unlinkSync path)
     (let [file-name   (-> (string/replace path (str repo "/") "")
-                        (string/replace "/" "_")
-                        (string/replace "\\" "_"))
+                          (string/replace "/" "_")
+                          (string/replace "\\" "_"))
           recycle-dir (str repo "/logseq/.recycle")
           _           (fs-extra/ensureDirSync recycle-dir)
           new-path    (str recycle-dir "/" file-name)]
       (fs/renameSync path new-path))))
 
+(defonce Diff (google-diff.))
+(defn string-some-deleted?
+  [old new]
+  (let [result (.diff_main Diff old new)]
+    (some (fn [a] (= -1 (first a))) result)))
+
+(defn- truncate-old-versioned-files!
+  [dir]
+  (let [files (fs/readdirSync dir (clj->js {:withFileTypes true}))
+        files (map #(.-name %) files)
+        old-versioned-files (drop 3 (reverse (sort files)))]
+    (doseq [file old-versioned-files]
+      (fs-extra/removeSync (path/join dir file)))))
+
+(defn- get-backup-dir
+  [repo path]
+  (let [path (string/replace path repo "")
+        bak-dir (str repo "/logseq/bak")
+        path (str bak-dir path)
+        parsed-path (path/parse path)]
+    (path/join (.-dir parsed-path)
+               (.-name parsed-path))))
+
 (defn backup-file
   [repo path content]
-  (let [file-name (-> (string/replace path (str repo "/") "")
-                      (string/replace "/" "_")
-                      (string/replace "\\" "_"))
-        bak-dir (str repo "/logseq/bak")
-        _ (fs-extra/ensureDirSync bak-dir)
-        new-path (str bak-dir "/" file-name "."
-                      (string/replace (.toISOString (js/Date.)) ":" "_"))]
+  (let [path-dir (get-backup-dir repo path)
+        ext (path/extname path)
+        new-path (path/join path-dir
+                            (str (string/replace (.toISOString (js/Date.)) ":" "_")
+                                 ext))]
+    (fs-extra/ensureDirSync path-dir)
     (fs/writeFileSync new-path content)
     (fs/statSync new-path)
+    (truncate-old-versioned-files! path-dir)
     new-path))
 
-(defmethod handle :backupDbFile [_window [_ repo path db-content]]
-  (backup-file repo path db-content))
+(defmethod handle :backupDbFile [_window [_ repo path db-content new-content]]
+  (when (and (string? db-content)
+             (string? new-content)
+             (string-some-deleted? db-content new-content))
+    (backup-file repo path db-content)))
+
+(defmethod handle :openFileBackupDir [_window [_ repo path]]
+  (when (string? path)
+    (let [dir (get-backup-dir repo path)]
+      (.openPath shell dir))))
 
 (defmethod handle :readFile [_window [_ path]]
   (utils/read-file path))
@@ -80,7 +113,7 @@
   (assert (string? path))
   (try
     (fs/accessSync path (aget fs "W_OK"))
-    (catch js/Error _e
+    (catch :default _e
       false)))
 
 (defmethod handle :writeFile [_window [_ repo path content]]
@@ -93,10 +126,10 @@
         (fs/chmodSync path "644"))
       (fs/writeFileSync path content)
       (fs/statSync path)
-      (catch js/Error e
+      (catch :default e
         (let [backup-path (try
                             (backup-file repo path content)
-                            (catch js/Error e
+                            (catch :default e
                               (println "Backup file failed")
                               (js/console.dir e)))]
           (utils/send-to-renderer "notification" {:type "error"
@@ -115,7 +148,7 @@
   (fs/statSync path))
 
 (defonce allowed-formats
-         #{:org :markdown :md :edn :json :js :css :excalidraw})
+  #{:org :markdown :md :edn :json :js :css :excalidraw})
 
 (defn get-ext
   [p]
@@ -126,16 +159,16 @@
 (defn- get-files
   [path]
   (let [result (->>
-                 (readdir path)
-                 (remove (partial utils/ignored-path? path))
-                 (filter #(contains? allowed-formats (get-ext %)))
-                 (map (fn [path]
-                        (let [stat (fs/statSync path)]
-                          (when-not (.isDirectory stat)
-                            {:path    (utils/fix-win-path! path)
-                             :content (utils/read-file path)
-                             :stat    stat}))))
-                 (remove nil?))]
+                (readdir path)
+                (remove (partial utils/ignored-path? path))
+                (filter #(contains? allowed-formats (get-ext %)))
+                (map (fn [path]
+                       (let [stat (fs/statSync path)]
+                         (when-not (.isDirectory stat)
+                           {:path    (utils/fix-win-path! path)
+                            :content (utils/read-file path)
+                            :stat    stat}))))
+                (remove nil?))]
     (vec (cons {:path (utils/fix-win-path! path)} result))))
 
 (defn open-dir-dialog []
@@ -160,12 +193,12 @@
         (string/replace "/" "++")
         (string/replace ":" "+3A+"))))
 
- (defn- graph-name->path
-   [graph-name]
-   (when graph-name
-     (-> graph-name
-         (string/replace "+3A+" ":")
-         (string/replace "++" "/"))))
+(defn- graph-name->path
+  [graph-name]
+  (when graph-name
+    (-> graph-name
+        (string/replace "+3A+" ":")
+        (string/replace "++" "/"))))
 
 (defn- get-graphs-dir
   []
@@ -215,12 +248,6 @@
       (when (fs/existsSync file-path)
         (fs-extra/removeSync file-path)))))
 
-(defmethod handle :openNewWindow [_window [_]]
-  (let [win (win/create-main-window)]
-    (win/on-close-save! win)
-    (win/setup-window-listeners! win)
-    nil))
-
 (defmethod handle :persistent-dbs-saved [_window _]
   (async/put! state/persistent-dbs-chan true)
   true)
@@ -268,11 +295,6 @@
   (clear-cache!)
   (search/ensure-search-dir!))
 
-(defmethod handle :addDirWatcher [window [_ dir]]
-  (watcher/close-watcher!)
-  (when dir
-    (watcher/watch-dir! window dir)))
-
 (defmethod handle :openDialog [^js _window _messages]
   (open-dir-dialog))
 
@@ -302,8 +324,16 @@
 (defmethod handle :getAppBaseInfo [^js win [_ _opts]]
   {:isFullScreen (.isFullScreen win)})
 
+(defn close-watcher-when-orphaned!
+  "When it's the last window for the directory, close the watcher."
+  [window dir]
+  (when (not (win/graph-has-other-windows? window dir))
+    (watcher/close-watcher! dir)))
+
 (defmethod handle :setCurrentGraph [^js win [_ path]]
-  (let [path (when path (utils/get-graph-dir path))]
+  (let [path (when path (utils/get-graph-dir path))
+        old-path (state/get-window-graph-path win)]
+    (when old-path (close-watcher-when-orphaned! win old-path))
     (swap! state/state assoc :graph/current path)
     (swap! state/state assoc-in [:window/graph win] path)
     nil))
@@ -338,35 +368,94 @@
                               (bean/->clj {:graph graph
                                            :tx-data tx-data})))))
 
-(defn- graph-has-other-windows? [win graph]
-  (let [dir (utils/get-graph-dir graph)
-        windows (win/get-graph-all-windows dir)
-        windows (filter #(.isVisible %) windows)]
-    (boolean (some (fn [^js window] (not= (.-id win) (.-id window))) windows))))
-
 (defmethod handle :graphHasOtherWindow [^js win [_ graph]]
-  (graph-has-other-windows? win graph))
+  (let [dir (utils/get-graph-dir graph)]
+    (win/graph-has-other-windows? win dir)))
 
 (defmethod handle :graphHasMultipleWindows [^js _win [_ graph]]
   (let [dir (utils/get-graph-dir graph)
-        windows (win/get-graph-all-windows dir)
-        windows (filter #(.isVisible %) windows)]
+        windows (win/get-graph-all-windows dir)]
+        ;; windows (filter #(.isVisible %) windows) ;; for mac .hide windows. such windows should also included
     (> (count windows) 1)))
 
+(defmethod handle :addDirWatcher [^js window [_ dir]]
+  ;; receive dir path (not repo / graph) from frontend
+  ;; Windows on same dir share the same watcher
+  ;; Only close file watcher when:
+  ;;    1. there is no one window on the same dir (TODO: check this on a window is closed)
+  ;;    2. reset file watcher to resend `add` event on window refreshing
+  (when dir
+    ;; adding dir watcher when the window has watcher already - must be cmd + r refreshing
+    ;; TODO: handle duplicated adding dir watcher when multiple windows
+    (close-watcher-when-orphaned! window dir)
+    (watcher/watch-dir! window dir)))
+
+(defmethod handle :openNewWindow [_window [_]]
+  (let [win (win/create-main-window)]
+    (win/on-close-actions! win close-watcher-when-orphaned!)
+    (win/setup-window-listeners! win)
+    nil))
+
 (defmethod handle :searchVersionChanged?
   [^js _win [_ graph]]
   (search/version-changed? graph))
 
+
 (defmethod handle :reloadWindowPage [^js win]
   (when-let [web-content (.-webContents win)]
     (.reload web-content)))
 
+
 (defmethod handle :setHttpsAgent [^js _win [_ opts]]
   (utils/set-fetch-agent opts))
 
+;;;;;;;;;;;;;;;;;;;;;;;
+;; file-sync-rs-apis ;;
+;;;;;;;;;;;;;;;;;;;;;;;
+
+(defmethod handle :get-local-files-meta [_ args]
+  (apply rsapi/get-local-files-meta (rest args)))
+
+(defmethod handle :get-local-all-files-meta [_ args]
+  (apply rsapi/get-local-all-files-meta (rest args)))
+
+(defmethod handle :rename-local-file [_ args]
+  (apply rsapi/rename-local-file (rest args)))
+
+(defmethod handle :delete-local-files [_ args]
+  (apply rsapi/delete-local-files (rest args)))
+
+(defmethod handle :update-local-files [_ args]
+  (apply rsapi/update-local-files (rest args)))
+
+(defmethod handle :delete-remote-files [_ args]
+  (apply rsapi/delete-remote-files (rest args)))
+
+(defmethod handle :update-remote-file [_ args]
+  (apply rsapi/update-remote-file (rest args)))
+
+(defmethod handle :update-remote-files [_ args]
+  (apply rsapi/update-remote-files (rest args)))
+
 (defmethod handle :default [args]
   (println "Error: no ipc handler for: " (bean/->js args)))
 
+(defmethod handle :persistGraph [^js win [_ graph]]
+  ;; call a window holds the specific graph to persist
+  (let [dir (utils/get-graph-dir graph)
+        windows (win/get-graph-all-windows dir)
+        ;; windows (filter #(.isVisible %) windows) ;; for mac .hide windows. such windows should also included
+        tar-graph-win (first windows)]
+    (if tar-graph-win
+      (utils/send-to-renderer tar-graph-win "persistGraph" graph)
+      (utils/send-to-renderer win "persistGraphDone" graph)))) ;; if no such graph, skip directly
+
+(defmethod handle :persistGraphDone [^js _win [_ graph]]
+  ;; when graph is persisted, broadcast it to all windows
+  (let [windows (win/get-all-windows)]
+    (doseq [window windows]
+      (utils/send-to-renderer window "persistGraphDone" graph))))
+
 (defn set-ipc-handler! [window]
   (let [main-channel "main"]
     (.handle ipcMain main-channel
@@ -378,6 +467,6 @@
                    (when-not (contains? #{"mkdir" "stat"} (nth args-js 0))
                      (println "IPC error: " {:event event
                                              :args args-js}
-                             e))
+                              e))
                    e))))
     #(.removeHandler ipcMain main-channel)))

+ 5 - 0
src/electron/electron/state.cljs

@@ -43,6 +43,11 @@
   []
   (:graph/current @state))
 
+(defn get-window-graph-path
+  "Get the path of the graph of a window (might be `nil`)"
+  [window]
+  (get (:window/graph @state) window))
+
 (defn close-window!
   [window]
   (swap! state medley/dissoc-in [:window/graph window]))

+ 12 - 4
src/electron/electron/window.cljs

@@ -1,6 +1,6 @@
 (ns electron.window
   (:require ["electron-window-state" :as windowStateKeeper]
-            [electron.utils :refer [mac? win32? linux? dev? logger open]]
+            [electron.utils :refer [mac? win32? linux? dev? logger open] :as utils]
             [electron.configs :as cfgs]
             [electron.context-menu :as context-menu]
             ["electron" :refer [BrowserWindow app session shell] :as electron]
@@ -68,10 +68,13 @@
   [^js win]
   (.destroy win))
 
-(defn on-close-save!
-  [^js win]
+(defn on-close-actions!
+  ;; TODO merge with the on close in core
+  [^js win close-watcher-f] ;; injected watcher related func
   (.on win "close" (fn [e]
                      (.preventDefault e)
+                     (when-let [dir (state/get-window-graph-path win)]
+                       (close-watcher-f win dir))
                      (state/close-window! win)
                      (let [web-contents (. win -webContents)]
                        (.send web-contents "persistent-dbs"))
@@ -86,11 +89,16 @@
   (.getAllWindows BrowserWindow))
 
 (defn get-graph-all-windows
-  [graph-path]
+  [graph-path] ;; graph-path == dir
   (->> (group-by second (:window/graph @state/state))
        (#(get % graph-path))
        (map first)))
 
+(defn graph-has-other-windows? [win dir]
+  (let [windows (get-graph-all-windows dir)]
+        ;; windows (filter #(.isVisible %) windows) ;; for mac .hide windows. such windows should also included
+    (boolean (some (fn [^js window] (not= (.-id win) (.-id window))) windows))))
+
 (defn- open-default-app!
   [url default-open]
   (let [URL (.-URL URL)

+ 36 - 29
src/main/electron/listener.cljs

@@ -1,43 +1,28 @@
 (ns electron.listener
   (:require [frontend.state :as state]
+            [frontend.context.i18n :refer [t]]
             [frontend.handler.route :as route-handler]
             [frontend.handler.ui :as ui-handler]
             [cljs-bean.core :as bean]
             [frontend.fs.watcher-handler :as watcher-handler]
+            [frontend.fs.sync :as sync]
             [frontend.db :as db]
             [datascript.core :as d]
-            [promesa.core :as p]
             [electron.ipc :as ipc]
+            [frontend.ui :as ui]
             [frontend.handler.notification :as notification]
-            [frontend.handler.metadata :as metadata-handler]
             [frontend.handler.repo :as repo-handler]
-            [frontend.ui :as ui]
-            [frontend.db.persist :as db-persist]))
+            [frontend.handler.user :as user]))
 
 (defn persist-dbs!
   []
-  (->
-   (p/let [repos (db-persist/get-all-graphs)
-           repos (-> repos
-                     (conj (state/get-current-repo))
-                     (distinct))]
-     (if (seq repos)
-       (do
-         (notification/show!
-          (ui/loading "Logseq is saving the graphs to your local file system, please wait for several seconds.")
-          :warning)
-         (doseq [repo repos]
-           (metadata-handler/set-pages-metadata! repo))
-         (js/setTimeout
-          (fn []
-            (-> (p/all (map db/persist! repos))
-                (p/then (fn []
-                          (ipc/ipc "persistent-dbs-saved")))))
-          100))
-       (ipc/ipc "persistent-dbs-saved")))
-   (p/catch (fn [error]
-              (js/console.error error)
-              (ipc/ipc "persistent-dbs-error")))))
+  ;; only persist current db!
+  ;; TODO rename the function and event to persist-db
+  (repo-handler/persist-db! {:before     #(notification/show!
+                                           (ui/loading (t :graph/persist))
+                                           :warning)
+                             :on-success #(ipc/ipc "persistent-dbs-saved")
+                             :on-error   #(ipc/ipc "persistent-dbs-error")}))
 
 (defn listen-persistent-dbs!
   []
@@ -53,7 +38,8 @@
   (js/window.apis.on "file-watcher"
                      (fn [data]
                        (let [{:keys [type payload]} (bean/->clj data)]
-                         (watcher-handler/handle-changed! type payload))))
+                         (watcher-handler/handle-changed! type payload)
+                         (sync/file-watch-handler type payload))))
 
   (js/window.apis.on "notification"
                      (fn [data]
@@ -71,7 +57,6 @@
                      (fn []
                        (state/pub-event! [:modal/set-git-username-and-email])))
 
-
   (js/window.apis.on "getCurrentGraph"
                      (fn []
                        (when-let [graph (state/get-current-repo)]
@@ -89,7 +74,29 @@
                              tx-data (db/string->db (:data tx-data))]
                          (when-let [conn (db/get-conn graph false)]
                            (d/transact! conn tx-data {:dbsync? true}))
-                         (ui-handler/re-render-root!)))))
+                         (ui-handler/re-render-root!))))
+
+  (js/window.apis.on "persistGraph"
+                     ;; electron is requesting window for persisting a graph in it's db
+                     (fn [data]
+                       (let [repo (bean/->clj data)
+                             before-f #(notification/show!
+                                        (ui/loading (t :graph/persist))
+                                        :warning)
+                             after-f #(ipc/ipc "persistGraphDone" repo)
+                             error-f (fn []
+                                       (after-f)
+                                       (notification/show!
+                                        (t :graph/persist-error)
+                                        :error))
+                             handlers {:before     before-f
+                                       :on-success after-f
+                                       :on-error   error-f}]
+                         (repo-handler/persist-db! repo handlers))))
+
+  (js/window.apis.on "loginCallback"
+                     (fn [code]
+                       (user/login-callback code))))
 
 (defn listen!
   []

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

@@ -247,7 +247,7 @@
 
        ["Upload an asset" [[:editor/click-hidden-file-input :id]] "Upload file types like image, pdf, docx, etc.)"]
 
-       (state/logged?)
+       (state/deprecated-logged?)
        ["Upload an image" [[:editor/click-hidden-file-input :id]]])]
 
     (markdown-headings)
@@ -496,7 +496,7 @@
 (defmethod handle-step :editor/restore-saved-cursor [[_]]
   (when-let [input-id (state/get-edit-input-id)]
     (when-let [current-input (gdom/getElement input-id)]
-      (cursor/move-cursor-to current-input (:editor/last-saved-cursor @state/state)))))
+      (cursor/move-cursor-to current-input (state/get-editor-last-pos)))))
 
 (defmethod handle-step :editor/clear-current-slash [[_ space?]]
   (when-let [input-id (state/get-edit-input-id)]

+ 310 - 327
src/main/frontend/components/block.cljs

@@ -68,9 +68,8 @@
   ([s warn?]
    (try
      (reader/read-string s)
-     (catch js/Error e
-       (println "read-string error:")
-       (js/console.error e)
+     (catch :default e
+       (log/error :read-string-error e :string s)
        (when warn?
          [:div.warning {:title "read-string failed"}
           s])))))
@@ -180,26 +179,26 @@
     (ui/resize-provider
      (ui/resize-consumer
       (cond->
-        {:className "resize image-resize"
-         :onSizeChanged (fn [value]
-                          (when (and (not @*resizing-image?)
-                                     (some? @size)
-                                     (not= value @size))
-                            (reset! *resizing-image? true))
-                          (reset! size value))
-         :onMouseUp (fn []
-                      (when (and @size @*resizing-image?)
-                        (when-let [block-id (:block/uuid config)]
-                          (let [size (bean/->clj @size)]
-                            (editor-handler/resize-image! block-id metadata full_text size))))
-                      (when @*resizing-image?
+       {:className "resize image-resize"
+        :onSizeChanged (fn [value]
+                         (when (and (not @*resizing-image?)
+                                    (some? @size)
+                                    (not= value @size))
+                           (reset! *resizing-image? true))
+                         (reset! size value))
+        :onMouseUp (fn []
+                     (when (and @size @*resizing-image?)
+                       (when-let [block-id (:block/uuid config)]
+                         (let [size (bean/->clj @size)]
+                           (editor-handler/resize-image! block-id metadata full_text size))))
+                     (when @*resizing-image?
                         ;; TODO: need a better way to prevent the clicking to edit current block
-                        (js/setTimeout #(reset! *resizing-image? false) 200)))
-         :onClick (fn [e]
-                    (when @*resizing-image? (util/stop e)))}
+                       (js/setTimeout #(reset! *resizing-image? false) 200)))
+        :onClick (fn [e]
+                   (when @*resizing-image? (util/stop e)))}
         (and (:width metadata) (not (util/mobile?)))
         (assoc :style {:width (:width metadata)}))
-      [:div.asset-container
+      [:div.asset-container {:key "resize-asset-container"}
        [:img.rounded-sm.shadow-xl.relative
         (merge
          {:loading "lazy"
@@ -423,7 +422,7 @@
                    page-name-in-block ;; page-name-in-block might be overrided (legacy)
                    original-name)
                _ (when-not page-entity (js/console.warn "page-inner's page-entity is nil, given page-name: " page-name
-                                                         " page-name-in-block: " page-name-in-block))]
+                                                        " page-name-in-block: " page-name-in-block))]
            (if tag? (str "#" s) s))))]))
 
 (rum/defc page-preview-trigger
@@ -576,19 +575,22 @@
     (editor-handler/edit-block! config :max (:block/uuid config))))
 
 (rum/defc block-embed < rum/reactive db-mixins/query
-  [config id]
-  (let [blocks (db/sub-block-and-children (state/get-current-repo) id)]
-    [:div.color-level.embed-block.bg-base-2
-     {:style {:z-index 2}
-      :on-double-click #(edit-parent-block % config)
-      :on-mouse-down (fn [e] (.stopPropagation e))}
-     [:div.px-3.pt-1.pb-2
-      (blocks-container blocks (assoc config
-                                      :id (str id)
-                                      :embed-id id
-                                      :embed? true
-                                      :embed-parent (:block config)
-                                      :ref? false))]]))
+  [config uuid]
+  (when-let [block (db/entity [:block/uuid uuid])]
+    (let [blocks (db/get-paginated-blocks (state/get-current-repo) (:db/id block)
+                                          {:scoped-block-id (:db/id block)})]
+      [:div.color-level.embed-block.bg-base-2
+       {:style {:z-index 2}
+        :on-double-click #(edit-parent-block % config)
+        :on-mouse-down (fn [e] (.stopPropagation e))}
+       [:div.px-3.pt-1.pb-2
+        (blocks-container blocks (assoc config
+                                        :db/id (:db/id block)
+                                        :id (str uuid)
+                                        :embed-id uuid
+                                        :embed? true
+                                        :embed-parent (:block config)
+                                        :ref? false))]])))
 
 (rum/defc page-embed < rum/reactive db-mixins/query
   [config page-name]
@@ -606,8 +608,10 @@
                   page-name)
             (not= (util/page-name-sanity-lc (get config :id ""))
                   page-name))
-       (let [blocks (db/get-page-blocks (state/get-current-repo) page-name)]
+       (let [page (model/get-page page-name)
+             blocks (db/get-paginated-blocks (state/get-current-repo) (:db/id page))]
          (blocks-container blocks (assoc config
+                                         :db/id (:db/id page)
                                          :id page-name
                                          :embed? true
                                          :page-embed? true
@@ -703,10 +707,12 @@
          (util/format "((%s))" id)]))))
 
 (defn inline-text
-  [format v]
-  (when (string? v)
-    (let [inline-list (mldoc/inline->edn v (mldoc/default-config format))]
-      [:div.inline.mr-1 (map-inline {} inline-list)])))
+  ([format v]
+   (inline-text {} format v))
+  ([config format v]
+   (when (string? v)
+     (let [inline-list (mldoc/inline->edn v (mldoc/default-config format))]
+       [:div.inline.mr-1 (map-inline config inline-list)]))))
 
 (defn- render-macro
   [config name arguments macro-content format]
@@ -741,27 +747,6 @@
                 (not (= (:id config) "contents")))
        [:span.text-gray-500 "]]"])]))
 
-(rum/defcs tutorial-video  <
-  (rum/local true)
-  [state]
-  (let [lite-mode? (:rum/local state)]
-    [:div.tutorial-video-container.relative
-     {:style {:height 367 :width 653}}
-     (if @lite-mode?
-       [:div
-        [:img.w-full.h-full.absolute
-         {:src "https://img.youtube.com/vi/Afmqowr0qEQ/maxresdefault.jpg"}]
-        [:button
-         {:class "absolute bg-red-300 w-16 h-16 -m-8 top-1/2 left-1/2 rounded-full"
-          :on-click (fn [_] (swap! lite-mode? not))}
-         (svg/play)]]
-       [:iframe.w-full.h-full
-        {:allow-full-screen "allowfullscreen"
-         :allow
-         "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
-         :frame-border "0"
-         :src "https://www.youtube.com/embed/Afmqowr0qEQ?autoplay=1"}])]))
-
 (declare custom-query)
 
 (defn- show-link?
@@ -881,7 +866,7 @@
             [:p.warning.text-sm "Block ref nesting is too deep"]
             (block-reference (assoc config
                                     :reference? true
-                                    :link-depth (inc link-depth) 
+                                    :link-depth (inc link-depth)
                                     :block/uuid id)
                              id label*)))
 
@@ -912,6 +897,10 @@
                (not= \* (last s)))
           (->elem :a {:on-click #(route-handler/jump-to-anchor! (mldoc/anchorLink (subs s 1)))} (subs s 1))
 
+          (text/block-ref? s)
+          (let [id (text/get-block-ref s)]
+            (block-reference config id label))
+
           (not (string/includes? s "."))
           (page-reference (:html-export? config) s config label)
 
@@ -943,9 +932,9 @@
             (->elem
              :a
              (cond->
-               {:href      (str "file://" path)
-                :data-href path
-                :target    "_blank"}
+              {:href      (str "file://" path)
+               :data-href path
+               :target    "_blank"}
                title
                (assoc :title title))
              (map-inline config label)))
@@ -959,16 +948,16 @@
                                   (and (= "File" (first url)) ["file" (second url)]))]
           (cond
             (and (= (get-in config [:block :block/format]) :org)
-                 (= "Complex" (first url))
-                 (= (string/lower-case protocol) "id")
-                 (string? (:link (second url)))
-                 (util/uuid-string? (:link (second url)))) ; org mode id
-            (let [id (uuid (:link (second url)))
+                 (= "Complex" protocol)
+                 (= (string/lower-case (:protocol path)) "id")
+                 (string? (:link path))
+                 (util/uuid-string? (:link path))) ; org mode id
+            (let [id (uuid (:link path))
                   block (db/entity [:block/uuid id])]
               (if (:block/pre-block? block)
                 (let [page (:block/page block)]
                   (page-reference html-export? (:block/name page) config label))
-                (block-reference config (:link (second url)) label)))
+                (block-reference config (:link path) label)))
 
             (= protocol "file")
             (cond
@@ -1002,9 +991,9 @@
                     (->elem
                      :a
                      (cond->
-                       {:href      (str "file://" href*)
-                        :data-href href*
-                        :target    "_blank"}
+                      {:href      (str "file://" href*)
+                       :data-href href*
+                       :target    "_blank"}
                        title
                        (assoc :title title))
                      (map-inline config label))))))
@@ -1030,8 +1019,8 @@
             (->elem
              :a.external-link
              (cond->
-               {:href (ar-url->http-url href)
-                :target "_blank"}
+              {:href (ar-url->http-url href)
+               :target "_blank"}
                title
                (assoc :title title))
              (map-inline config label))
@@ -1040,8 +1029,8 @@
             (->elem
              :a.external-link
              (cond->
-               {:href href
-                :target "_blank"}
+              {:href href
+               :target "_blank"}
                title
                (assoc :title title))
              (map-inline config label))))))
@@ -1171,9 +1160,6 @@
           (when-let [seconds (youtube/parse-timestamp timestamp)]
             (youtube/timestamp seconds)))
 
-        (= name "tutorial-video")
-        (tutorial-video)
-
         (= name "zotero-imported-file")
         (let [[item-key filename] arguments]
           (when (and item-key filename)
@@ -1263,7 +1249,7 @@
             nil))
 
         (and plugin-handler/lsp-enabled? (= name "renderer"))
-        (when-let [block-uuid (str (:block/uuid config))]
+        (when-let [block-uuid (not-empty (str (:block/uuid config)))]
           (plugins/hook-ui-slot :macro-renderer-slotted (assoc options :uuid block-uuid)))
 
         (get @macro/macros name)
@@ -1395,9 +1381,9 @@
             (when (map? child)
               (let [child (dissoc child :block/meta)
                     config (cond->
-                             (-> config
-                                 (assoc :block/uuid (:block/uuid child))
-                                 (dissoc :breadcrumb-show? :embed-parent))
+                            (-> config
+                                (assoc :block/uuid (:block/uuid child))
+                                (dissoc :breadcrumb-show? :embed-parent))
                              ref?
                              (assoc :ref-child? true))]
                 (rum/with-key (block-container config child)
@@ -1415,12 +1401,8 @@
    (every? #(= % ["Horizontal_Rule"]) body)))
 
 (rum/defcs block-control < rum/reactive
-  [state config block uuid block-id children collapsed? *control-show? edit?]
+  [state config block uuid block-id collapsed? *control-show? edit?]
   (let [doc-mode? (state/sub :document/mode?)
-        has-children-blocks? (and (coll? children) (seq children))
-        has-child? (and
-                    (not (:pre-block? block))
-                    has-children-blocks?)
         control-show? (util/react *control-show?)
         ref? (:ref? config)
         empty-content? (block-content-empty? block)]
@@ -1433,12 +1415,12 @@
       {:id (str "control-" uuid)
        :on-click (fn [event]
                    (util/stop event)
-                   (when-not (and (not collapsed?) (not has-child?))
-                     (if ref?
-                       (state/toggle-collapsed-block! uuid)
-                       (if collapsed?
-                         (editor-handler/expand-block! uuid)
-                         (editor-handler/collapse-block! uuid)))))}
+                   (state/clear-edit!)
+                   (if ref?
+                     (state/toggle-collapsed-block! uuid)
+                     (if collapsed?
+                       (editor-handler/expand-block! uuid)
+                       (editor-handler/collapse-block! uuid))))}
       [:span {:class (if control-show? "control-show cursor-pointer" "control-hide")}
        (ui/rotating-arrow collapsed?)]]
      (let [bullet [:a {:on-click (fn [event]
@@ -1510,17 +1492,25 @@
                                    (editor-handler/check block)))}))))
 
 (defn list-checkbox
-  [checked?]
-  (ui/checkbox {:style {:margin-right 6}
-                :checked checked?}))
+  [config checked?]
+  (ui/checkbox
+   {:style {:margin-right 6}
+    :checked checked?
+    :on-change (fn [event]
+                 (let [target (.-target event)
+                       block (:block config)
+                       item-content (.. target -nextSibling -data)
+                       item-full-content (str (if checked? "[X]" "[ ]") " " item-content)
+                       new-item-full-content (str (if checked? "[ ]" "[X]") " " item-content)]
+                   (editor-handler/toggle-list-checkbox block item-full-content new-item-full-content)))}))
 
 (defn marker-switch
   [{:block/keys [marker] :as block}]
   (when (contains? #{"NOW" "LATER" "TODO" "DOING"} marker)
-    (let [set-marker-fn (fn [marker]
+    (let [set-marker-fn (fn [new-marker]
                           (fn [e]
                             (util/stop e)
-                            (editor-handler/set-marker block marker)))
+                            (editor-handler/set-marker block new-marker)))
           next-marker (case marker
                         "NOW" "LATER"
                         "LATER" "NOW"
@@ -1682,7 +1672,7 @@
        (util/unquote-string v)
 
        :else
-       (inline-text (:block/format block) (str v)))]))
+       (inline-text config (:block/format block) (str v)))]))
 
 (rum/defc properties-cp
   [config block]
@@ -1823,24 +1813,24 @@
            (not (:block/pre-block? block)))
       (let [move-to (rum/react *move-to)]
         (when-not
-            (or (and top? (not= move-to :top))
-                (and (not top?) (= move-to :top))
-                (and block-content? (not= move-to :nested))
-                (and (not block-content?)
-                     (seq (:block/children block))
-                     (= move-to :nested)))
+         (or (and top? (not= move-to :top))
+             (and (not top?) (= move-to :top))
+             (and block-content? (not= move-to :nested))
+             (and (not block-content?)
+                  (seq (:block/children block))
+                  (= move-to :nested)))
           (dnd-separator move-to block-content?))))))
 
 (defn clock-summary-cp
   [block body]
-  [:div {:style {:max-width 100}}
-   (when (and (state/enable-timetracking?)
-              (or (= (:block/marker block) "DONE")
-                  (contains? #{"TODO" "LATER"} (:block/marker block))))
-     (let [summary (clock/clock-summary body true)]
-       (when (and summary
-                  (not= summary "0m")
-                  (not (string/blank? summary)))
+  (when (and (state/enable-timetracking?)
+             (or (= (:block/marker block) "DONE")
+                 (contains? #{"TODO" "LATER"} (:block/marker block))))
+    (let [summary (clock/clock-summary body true)]
+      (when (and summary
+                 (not= summary "0m")
+                 (not (string/blank? summary)))
+        [:div {:style {:max-width 100}}
          (ui/tippy {:html        (fn []
                                    (when-let [logbook (drawer/get-logbook body)]
                                      (let [clocks (->> (last logbook)
@@ -1855,7 +1845,7 @@
                     :delay       [1000, 100]}
                    [:div.text-sm.time-spent.ml-1 {:style {:padding-top 3}}
                     [:a.fade-link
-                     summary]]))))])
+                     summary]])]))))
 
 (rum/defc block-content < rum/reactive
   [config {:block/keys [uuid content children properties scheduled deadline format pre-block?] :as block} edit-input-id block-id slide?]
@@ -1871,9 +1861,9 @@
                          :on-mouse-down ; TODO: it seems that Safari doesn't work well with on-mouse-down
                          )
         attrs (cond->
-                {:blockid       (str uuid)
-                 :data-type (name block-type)
-                 :style {:width "100%"}}
+               {:blockid       (str uuid)
+                :data-type (name block-type)
+                :style {:width "100%"}}
                 (not block-ref?)
                 (assoc mouse-down-key (fn [e]
                                         (block-content-on-mouse-down e block block-id content edit-input-id))))]
@@ -1893,7 +1883,7 @@
        (not slide?)
        (merge attrs))
 
-     [:span
+     [:<>
       [:div.flex.flex-row.justify-between
        [:div.flex-1
         (cond
@@ -1937,10 +1927,10 @@
         [:p.warning.text-sm "Full content is not displayed, Logseq doesn't support multiple unordered lists or headings in a block."]
         nil)]]))
 
-(rum/defc block-refs-count < rum/reactive db-mixins/query
+(rum/defc block-refs-count < rum/static
   [block]
-  (let [block-refs-count (model/get-block-refs-count (:db/id block))]
-    (when (and block-refs-count (> block-refs-count 0))
+  (let [block-refs-count (count (:block/_refs block))]
+    (when (> block-refs-count 0)
       [:div
        [:a.open-block-ref-link.bg-base-2.text-sm.ml-2.fade-link
         {:title "Open block references"
@@ -1953,18 +1943,6 @@
                       {:block block}))}
         block-refs-count]])))
 
-(rum/defc block-content-fallback
-  [edit-input-id block]
-  (let [content (:block/content block)]
-    [:section.border.mt-1.p-1.cursor-pointer.block-content-fallback-ui
-     {:on-click #(state/set-editing! edit-input-id content block "")}
-     [:div.flex.justify-between.items-center.px-1
-      [:h5.text-red-600.pb-1 "Block Render Error:"]
-      [:a.text-xs.opacity-50.hover:opacity-80
-       {:href "https://github.com/logseq/logseq/issues"
-        :target "_blank"} "report issue"]]
-     [:pre.m-0.text-sm content]]))
-
 (rum/defc block-content-or-editor < rum/reactive
   [config {:block/keys [uuid format] :as block} edit-input-id block-id heading-level edit?]
   (let [editor-box (get config :editor-box)
@@ -1974,23 +1952,27 @@
     (if (and edit? (not reading-mode?) editor-box)
       [:div.editor-wrapper {:id editor-id}
        (ui/catch-error
-        [:p.warning "Something wrong in the editor"]
+        (ui/block-error "Something wrong in the editor" {})
         (editor-box {:block block
                      :block-id uuid
                      :block-parent-id block-id
                      :format format
                      :heading-level heading-level
-                     :on-hide (fn [_value event]
+                     :on-hide (fn [value event]
                                 (when (= event :esc)
+                                  (editor-handler/save-block! (editor-handler/get-state) value)
                                   (editor-handler/escape-editing)))}
                     edit-input-id
                     config))]
       [:div.flex.flex-row.block-content-wrapper
        [:div.flex-1.w-full {:style {:display (if (:slide? config) "block" "flex")}}
         (ui/catch-error
-         (block-content-fallback edit-input-id block)
+         (ui/block-error "Block Render Error:"
+                         {:content (:block/content block)
+                          :section-attrs
+                          {:on-click #(state/set-editing! edit-input-id (:block/content block) block "")}})
          (block-content config block edit-input-id block-id slide?))]
-       [:div.flex.flex-row
+       [:div.flex.flex-row.items-center
         (when (and (:embed? config)
                    (:embed-parent config))
           [:a.opacity-30.hover:opacity-100.svg-small.inline
@@ -2130,7 +2112,7 @@
   (util/stop e)
   (when (or
          (model/block-collapsed? uuid)
-         (editor-handler/collapsable? uuid))
+         (editor-handler/collapsable? uuid {:semantic? true}))
     (reset! *control-show? true))
   (when-let [parent (gdom/getElement block-id)]
     (let [node (.querySelector parent ".bullet-container")]
@@ -2206,7 +2188,7 @@
                nil)
              (assoc state ::control-show? (atom false))))
    :should-update (fn [old-state new-state]
-                    (let [compare-keys [:block/uuid :block/content :block/parent :block/collapsed? :block/children
+                    (let [compare-keys [:block/uuid :block/content :block/parent :block/collapsed?
                                         :block/properties :block/left :block/children :block/_refs]
                           config-compare-keys [:show-cloze?]
                           b1 (second (:rum/args old-state))
@@ -2298,7 +2280,7 @@
        :on-mouse-leave (fn [e]
                          (block-mouse-leave e *control-show? block-id doc-mode?))}
       (when (not slide?)
-        (block-control config block uuid block-id children collapsed? *control-show? edit?))
+        (block-control config block uuid block-id collapsed? *control-show? edit?))
 
       (block-content-or-editor config block edit-input-id block-id heading-level edit?)]
 
@@ -2369,7 +2351,7 @@
         (->elem
          :li
          (cond->
-           {:checked checked?}
+          {:checked checked?}
            number
            (assoc :value number))
          (vec-cat
@@ -2383,7 +2365,7 @@
          (vec-cat
           [(->elem
             :p
-            (list-checkbox checkbox)
+            (list-checkbox config checkbox)
             content)]
           [items]))))))
 
@@ -2476,28 +2458,28 @@
         result-atom (atom nil)
         query-atom (if (:dsl-query? config)
                      (let [q (:query query)
-                           result (query-dsl/query (state/get-current-repo) q)]
+                           form (safe-read-string q false)]
                        (cond
-                         (and (util/electron?) (string? result)) ; full-text search
-                         (if (string/blank? result)
-                           (atom [])
-                           (p/let [blocks (search/block-search repo (string/trim result) {:limit 30})]
-                             (when (seq blocks)
-                               (let [result (db/pull-many (state/get-current-repo) '[*] (map (fn [b] [:block/uuid (uuid (:block/uuid b))]) blocks))]
-                                 (reset! result-atom result)))))
-
-                         (string? result)
+                          ;; Searches like 'foo' or 'foo bar' come back as symbols
+                         ;; and are meant to go directly to full text search
+                         (and (util/electron?) (symbol? form)) ; full-text search
+                         (p/let [blocks (search/block-search repo (string/trim (str form)) {:limit 30})]
+                           (when (seq blocks)
+                             (let [result (db/pull-many (state/get-current-repo) '[*] (map (fn [b] [:block/uuid (uuid (:block/uuid b))]) blocks))]
+                               (reset! result-atom result))))
+
+                         (symbol? form)
                          (atom nil)
 
                          :else
-                         result))
+                         (query-dsl/query (state/get-current-repo) q)))
                      (db/custom-query query))
         query-atom (if (instance? Atom query-atom)
                      query-atom
                      result-atom)]
     (assoc state :query-atom query-atom)))
 
-(rum/defcs ^:large-vars/cleanup-todo custom-query < rum/reactive
+(rum/defcs ^:large-vars/cleanup-todo custom-query* < rum/reactive
   {:will-mount trigger-custom-query!
    :did-mount (fn [state]
                 (when-let [query (last (:rum/args state))]
@@ -2511,119 +2493,121 @@
                      (db/remove-custom-query! (state/get-current-repo) query))
                    state)}
   [state config {:keys [title query view collapsed? children? breadcrumb-show?] :as q}]
+  (let [dsl-query? (:dsl-query? config)
+        query-atom (:query-atom state)
+        current-block-uuid (or (:block/uuid (:block config))
+                               (:block/uuid config))
+        current-block (db/entity [:block/uuid current-block-uuid])
+        ;; exclude the current one, otherwise it'll loop forever
+        remove-blocks (if current-block-uuid [current-block-uuid] nil)
+        query-result (and query-atom (rum/react query-atom))
+        table? (or (get-in current-block [:block/properties :query-table])
+                   (and (string? query) (string/ends-with? (string/trim query) "table")))
+        transformed-query-result (when query-result
+                                   (db/custom-query-result-transform query-result remove-blocks q))
+        not-grouped-by-page? (or table?
+                                 (boolean (:result-transform q))
+                                 (and (string? query) (string/includes? query "(by-page false)")))
+        result (if (and (:block/uuid (first transformed-query-result)) (not not-grouped-by-page?))
+                 (db-utils/group-by-page transformed-query-result)
+                 transformed-query-result)
+        _ (when-let [query-result (:query-result config)]
+            (let [result (remove (fn [b] (some? (get-in b [:block/properties :template]))) result)]
+              (reset! query-result result)))
+        view-f (and view (sci/eval-string (pr-str view)))
+        only-blocks? (:block/uuid (first result))
+        blocks-grouped-by-page? (and (seq result)
+                                     (not not-grouped-by-page?)
+                                     (coll? (first result))
+                                     (:block/name (ffirst result))
+                                     (:block/uuid (first (second (first result))))
+                                     true)
+        built-in? (built-in-custom-query? title)
+        page-list? (and (seq result)
+                        (:block/name (first result)))
+        nested-query? (:custom-query? config)]
+    (if nested-query?
+      [:code (if dsl-query?
+               (util/format "{{query %s}}" query)
+               "{{query hidden}}")]
+      [:div.custom-query.mt-4 (get config :attr {})
+       (when-not (and built-in? (empty? result))
+         (ui/foldable
+          [:div.custom-query-title
+           [:span.title-text title]
+           [:span.opacity-60.text-sm.ml-2.results-count
+            (str (count transformed-query-result) " results")]]
+          (fn []
+            [:div
+             (when current-block
+               [:div.flex.flex-row.align-items.mt-2 {:on-mouse-down (fn [e] (util/stop e))}
+                (when-not page-list?
+                  [:div.flex.flex-row
+                   [:div.mx-2 [:span.text-sm "Table view"]]
+                   [:div {:style {:margin-top 5}}
+                    (ui/toggle table?
+                               (fn []
+                                 (editor-handler/set-block-property! current-block-uuid
+                                                                     "query-table"
+                                                                     (not table?)))
+                               true)]])
+
+                [:a.mx-2.block.fade-link
+                 {:on-click (fn []
+                              (let [all-keys (query-table/get-keys result page-list?)]
+                                (state/pub-event! [:modal/set-query-properties current-block all-keys])))}
+                 [:span.table-query-properties
+                  [:span.text-sm.mr-1 "Set properties"]
+                  svg/settings-sm]]])
+             (cond
+               (and (seq result) view-f)
+               (let [result (try
+                              (sci/call-fn view-f result)
+                              (catch js/Error error
+                                (log/error :custom-view-failed {:error error
+                                                                :result result})
+                                [:div "Custom view failed: "
+                                 (str error)]))]
+                 (util/hiccup-keywordize result))
+
+               page-list?
+               (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text)
+
+               table?
+               (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text)
+
+               (and (seq result) (or only-blocks? blocks-grouped-by-page?))
+               (->hiccup result (cond-> (assoc config
+                                               :custom-query? true
+                                               :breadcrumb-show? (if (some? breadcrumb-show?)
+                                                                   breadcrumb-show?
+                                                                   true)
+                                               :group-by-page? blocks-grouped-by-page?
+                                               :ref? true)
+                                  children?
+                                  (assoc :ref? true))
+                         {:style {:margin-top "0.25rem"
+                                  :margin-left "0.25rem"}})
+
+               (seq result)
+               (let [result (->>
+                             (for [record result]
+                               (if (map? record)
+                                 (str (util/pp-str record) "\n")
+                                 record))
+                             (remove nil?))]
+                 [:pre result])
+
+               :else
+               [:div.text-sm.mt-2.ml-2.font-medium.opacity-50 "Empty"])])
+          {:default-collapsed? collapsed?
+           :title-trigger? true}))])))
+
+(rum/defc custom-query
+  [config q]
   (ui/catch-error
-   [:div.warning
-    [:p "Query failed: "]
-    [:pre (str q)]]
-   (let [dsl-query? (:dsl-query? config)
-         query-atom (:query-atom state)
-         current-block-uuid (or (:block/uuid (:block config))
-                                (:block/uuid config))
-         current-block (db/entity [:block/uuid current-block-uuid])
-         ;; exclude the current one, otherwise it'll loop forever
-         remove-blocks (if current-block-uuid [current-block-uuid] nil)
-         query-result (and query-atom (rum/react query-atom))
-         table? (or (get-in current-block [:block/properties :query-table])
-                    (and (string? query) (string/ends-with? (string/trim query) "table")))
-         transformed-query-result (when query-result
-                                    (db/custom-query-result-transform query-result remove-blocks q))
-         not-grouped-by-page? (or table?
-                                  (boolean (:result-transform q))
-                                  (and (string? query) (string/includes? query "(by-page false)")))
-         result (if (and (:block/uuid (first transformed-query-result)) (not not-grouped-by-page?))
-                  (db-utils/group-by-page transformed-query-result)
-                  transformed-query-result)
-         _ (when-let [query-result (:query-result config)]
-             (let [result (remove (fn [b] (some? (get-in b [:block/properties :template]))) result)]
-               (reset! query-result result)))
-         view-f (and view (sci/eval-string (pr-str view)))
-         only-blocks? (:block/uuid (first result))
-         blocks-grouped-by-page? (and (seq result)
-                                      (not not-grouped-by-page?)
-                                      (coll? (first result))
-                                      (:block/name (ffirst result))
-                                      (:block/uuid (first (second (first result))))
-                                      true)
-         built-in? (built-in-custom-query? title)
-         page-list? (and (seq result)
-                         (:block/name (first result)))
-         nested-query? (:custom-query? config)]
-     (if nested-query?
-       [:code (if dsl-query?
-                (util/format "{{query %s}}" query)
-                "{{query hidden}}")]
-       [:div.custom-query.mt-4 (get config :attr {})
-        (when-not (and built-in? (empty? result))
-          (ui/foldable
-           [:div.custom-query-title
-            [:span.title-text title]
-            [:span.opacity-60.text-sm.ml-2.results-count
-             (str (count transformed-query-result) " results")]]
-           (fn []
-             [:div
-              (when current-block
-                [:div.flex.flex-row.align-items.mt-2 {:on-mouse-down (fn [e] (util/stop e))}
-                 (when-not page-list?
-                   [:div.flex.flex-row
-                    [:div.mx-2 [:span.text-sm "Table view"]]
-                    [:div {:style {:margin-top 5}}
-                     (ui/toggle table?
-                                (fn []
-                                  (editor-handler/set-block-property! current-block-uuid
-                                                                      "query-table"
-                                                                      (not table?)))
-                                true)]])
-
-                 [:a.mx-2.block.fade-link
-                  {:on-click (fn []
-                               (let [all-keys (query-table/get-keys result page-list?)]
-                                 (state/pub-event! [:modal/set-query-properties current-block all-keys])))}
-                  [:span.table-query-properties
-                   [:span.text-sm.mr-1 "Set properties"]
-                   svg/settings-sm]]])
-              (cond
-                (and (seq result) view-f)
-                (let [result (try
-                               (sci/call-fn view-f result)
-                               (catch js/Error error
-                                 (log/error :custom-view-failed {:error error
-                                                                 :result result})
-                                 [:div "Custom view failed: "
-                                  (str error)]))]
-                  (util/hiccup-keywordize result))
-
-                page-list?
-                (query-table/result-table config current-block result {:page? true} map-inline page-cp ->elem inline-text)
-
-                table?
-                (query-table/result-table config current-block result {:page? false} map-inline page-cp ->elem inline-text)
-
-                (and (seq result) (or only-blocks? blocks-grouped-by-page?))
-                (->hiccup result (cond-> (assoc config
-                                                :custom-query? true
-                                                :breadcrumb-show? (if (some? breadcrumb-show?)
-                                                                    breadcrumb-show?
-                                                                    true)
-                                                :group-by-page? blocks-grouped-by-page?
-                                                :ref? true)
-                                   children?
-                                   (assoc :ref? true))
-                          {:style {:margin-top "0.25rem"
-                                   :margin-left "0.25rem"}})
-
-                (seq result)
-                (let [result (->>
-                              (for [record result]
-                                (if (map? record)
-                                  (str (util/pp-str record) "\n")
-                                  record))
-                              (remove nil?))]
-                  [:pre result])
-
-                :else
-                [:div.text-sm.mt-2.ml-2.font-medium.opacity-50 "Empty"])])
-           {:default-collapsed? collapsed?
-            :title-trigger? true}))]))))
+   (ui/block-error "Query Error:" {:content (:query q)})
+   (custom-query* config q)))
 
 (defn admonition
   [config type result]
@@ -2730,8 +2714,17 @@
                   (interpose [:span ", "] vals))
                 (inline-text format v))]))]
 
+             ;; for file-level property in orgmode: #+key: value
+             ;; only display caption. https://orgmode.org/manual/Captions.html.
+        ["Directive" key value]
+        [:div.file-level-property
+         (when (contains? #{"caption"} (string/lower-case key))
+           [:span.font-medium
+            [:span.font-bold (string/upper-case key)]
+            (str ": " value)])]
+
         ["Paragraph" l]
-        ;; TODO: speedup
+             ;; TODO: speedup
         (if (util/safe-re-find #"\"Export_Snippet\" \"embed\"" (str l))
           (->elem :div (map-inline config l))
           (->elem :div.is-paragraph (map-inline config l)))
@@ -2788,16 +2781,11 @@
 
         ["Custom" "query" _options _result content]
         (try
-          (let [query (reader/read-string content)
-                query (if (string? query)
-                        (string/trim query)
-                        query)]
+          (let [query (reader/read-string content)]
             (custom-query config query))
-          (catch js/Error e
-            (println "read-string error:")
-            (js/console.error e)
-            [:div.warning {:title "Invalid query"}
-             content]))
+          (catch :default e
+            (log/error :read-string-error e)
+            (ui/block-error "Invalid query:" {:content content})))
 
         ["Custom" "note" _options result _content]
         (admonition config "note" result)
@@ -2868,16 +2856,20 @@
   [config col]
   (map #(markup-element-cp config %) col))
 
+(defn- block-item
+  [config blocks idx item]
+  (let [item (->
+              (dissoc item :block/meta)
+              (assoc :block/top? (zero? idx)
+                     :block/bottom? (= (count blocks) (inc idx))))
+        config (assoc config :block/uuid (:block/uuid item))]
+    (rum/with-key (block-container config item)
+      (str (:block/uuid item)))))
+
 (defn- block-list
   [config blocks]
   (for [[idx item] (medley/indexed blocks)]
-    (let [item (->
-                (dissoc item :block/meta)
-                (assoc :block/top? (zero? idx)
-                       :block/bottom? (= (count blocks) (inc idx))))
-          config (assoc config :block/uuid (:block/uuid item))]
-      (rum/with-key (block-container config item)
-        (str (:block/uuid item))))))
+    (block-item config blocks idx item)))
 
 (defn- custom-query-or-ref?
   [config]
@@ -2885,50 +2877,40 @@
         custom-query? (:custom-query? config)]
     (or custom-query? ref?)))
 
-;; TODO: virtual tree for better UX and memory usage reduce
-
-
-(defn- get-segment
-  [_config flat-blocks idx blocks->vec-tree]
-  (let [new-idx (if (< idx block-handler/initial-blocks-length)
-                  block-handler/initial-blocks-length
-                  (+ idx block-handler/step-loading-blocks))
-        max-idx (count flat-blocks)
-        idx (min max-idx new-idx)
-        blocks (util/safe-subvec flat-blocks 0 idx)]
-    [(blocks->vec-tree blocks)
-     idx]))
+(defn- load-more-blocks!
+  [config flat-blocks]
+  (when-let [db-id (:db/id config)]
+    (let [last-block-id (:db/id (last flat-blocks))]
+      (block-handler/load-more! db-id last-block-id))))
 
 (rum/defcs lazy-blocks < rum/reactive
-  {:did-remount (fn [_old-state new-state]
-                  ;; Loading more when pressing Enter or paste
-                  (let [*last-idx (::last-idx new-state)
-                        new-idx (if (zero? *last-idx)
-                                  1
-                                  (inc @*last-idx))]
-                    (reset! *last-idx new-idx))
-                  new-state)}
-  (rum/local 0 ::last-idx)
+  {:init (fn [state]
+           (assoc state ::id (str (random-uuid))))}
   [state config flat-blocks blocks->vec-tree]
-  (let [*last-idx (::last-idx state)
-        [segment idx] (get-segment config
-                                   flat-blocks
-                                   @*last-idx
-                                   blocks->vec-tree)
-        bottom-reached (fn [] (reset! *last-idx idx))
-        has-more? (>= (count flat-blocks) (inc idx))]
-    [:div#lazy-blocks
-     (ui/infinite-list
-      "main-content-container"
-      (block-list config segment)
-      {:on-load bottom-reached
-       :bottom-reached (fn []
-                         (when-let [node (gdom/getElement "lazy-blocks")]
-                           (ui/bottom-reached? node 1000)))
-       :has-more has-more?
-       :more (if (or (:preview? config) (:sidebar? config))
-               "More"
-               (ui/loading "Loading"))})]))
+  (let [db-id (:db/id config)
+        blocks (blocks->vec-tree flat-blocks)]
+    (if-not db-id
+      (block-list config blocks)
+      (let [bottom-reached (fn []
+                             ;; To prevent scrolling after inserting new blocks
+                             (when (> (- (util/time-ms) (:start-time config)) 100)
+                               (load-more-blocks! config flat-blocks)))
+            has-more? (and
+                       (> (count flat-blocks) model/initial-blocks-length)
+                       (some? (model/get-next-open-block (db/get-conn) (last flat-blocks) db-id)))
+            dom-id (str "lazy-blocks-" (::id state))]
+        [:div {:id dom-id}
+         (ui/infinite-list
+          "main-content-container"
+          (block-list config blocks)
+          {:on-load bottom-reached
+           :bottom-reached (fn []
+                             (when-let [node (gdom/getElement dom-id)]
+                               (ui/bottom-reached? node 1000)))
+           :has-more has-more?
+           :more (if (or (:preview? config) (:sidebar? config))
+                   "More"
+                   (ui/loading "Loading"))})]))))
 
 (rum/defcs blocks-container <
   {:init (fn [state]
@@ -2944,7 +2926,8 @@
         doc-mode? (:document/mode? config)]
     (when (seq blocks)
       (let [blocks->vec-tree #(if (custom-query-or-ref? config) % (tree/blocks->vec-tree % (:id config)))
-            flat-blocks (vec blocks)]
+            flat-blocks (vec blocks)
+            config (assoc config :start-time (util/time-ms))]
         [:div.blocks-container.flex-1
          {:class (when doc-mode? "document-mode")}
          (lazy-blocks config flat-blocks blocks->vec-tree)]))))

+ 2 - 2
src/main/frontend/components/block.css

@@ -544,11 +544,11 @@ a[data-ref="card"], .page-reference[data-ref="card"] {
     display: none;
 }
 
-span.cloze {
+a.cloze {
     color: var(--ls-cloze-text-color);
 }
 
-span.cloze-revealed {
+a.cloze-revealed {
     color: var(--ls-cloze-text-color);
     text-decoration: underline;
     text-underline-position: under;

+ 7 - 4
src/main/frontend/components/content.cljs

@@ -153,10 +153,13 @@
 
     (rum/use-effect!
      (fn []
-       (let [^js el (rum/deref *el-ref)
-             {:keys [x y]} (util/calc-delta-rect-offset el js/document.documentElement)]
-         (set! (.. el -style -transform)
-               (str "translate3d(" (if (neg? x) x 0) "px," (if (neg? y) (- y 10) 0) "px" ",0)")))
+       (js/setTimeout
+        (fn []
+          (let [^js el (rum/deref *el-ref)
+               {:keys [x y]} (util/calc-delta-rect-offset el js/document.documentElement)]
+           (set! (.. el -style -transform)
+                 (str "translate3d(" (if (neg? x) x 0) "px," (if (neg? y) (- y 10) 0) "px" ",0)"))))
+        10)
        #())
      [])
 

+ 4 - 2
src/main/frontend/components/content.css

@@ -13,7 +13,9 @@
 
 #custom-context-menu {
   @apply rounded-md shadow-lg transition ease-out duration-100 transform
-  opacity-100 scale-100 absolute;
+  opacity-100 scale-100 absolute overflow-y-auto;
 
+  max-height: calc(100vh - 100px) !important;;
+  overflow-y: scroll;
   z-index: calc(var(--ls-z-index-level-1) + 1);
-}
+}

+ 23 - 29
src/main/frontend/components/editor.cljs

@@ -101,7 +101,7 @@
   "Embedded page searching popup"
   [id format]
   (when (state/sub :editor/show-page-search?)
-    (let [pos (:editor/last-saved-cursor @state/state)
+    (let [pos (state/get-editor-last-pos)
           input (gdom/getElement id)]
       (when input
         (let [current-pos (cursor/pos input)
@@ -191,7 +191,7 @@
                    state)}
   [state id _format]
   (when (state/sub :editor/show-block-search?)
-    (let [pos (:editor/last-saved-cursor @state/state)
+    (let [pos (state/get-editor-last-pos)
           input (gdom/getElement id)
           [id format] (:rum/args state)
           current-pos (cursor/pos input)
@@ -208,7 +208,7 @@
   {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
   [id _format]
   (when (state/sub :editor/show-template-search?)
-    (let [pos (:editor/last-saved-cursor @state/state)
+    (let [pos (state/get-editor-last-pos)
           input (gdom/getElement id)]
       (when input
         (let [current-pos (cursor/pos input)
@@ -234,24 +234,17 @@
    [:button.bottom-action
     {:on-mouse-down (fn [e]
                       (util/stop e)
-                      (state/set-state! :editor/pos (cursor/pos (state/get-input)))
                       (editor-handler/indent-outdent indent?))}
     (ui/icon icon {:style {:fontSize ui/icon-size}})]])
 
-(rum/defc mobile-bar-command [command-handler icon]
+(rum/defc mobile-bar-command [command-handler icon & [event?]]
   [:div
    [:button.bottom-action
     {:on-mouse-down (fn [e]
                       (util/stop e)
-                      (command-handler))}
-    (ui/icon icon {:style {:fontSize ui/icon-size}})]])
-
-(rum/defc mobile-bar-command-with-event [command-handler icon]
-  [:div
-   [:button.bottom-action
-    {:on-mouse-down (fn [e]
-                      (util/stop e)
-                      (command-handler e))}
+                      (if event?
+                        (command-handler e)
+                        (command-handler)))}
     (ui/icon icon {:style {:fontSize ui/icon-size}})]])
 
 (rum/defc mobile-bar < rum/reactive
@@ -285,8 +278,8 @@
       (mobile-bar-command #(mobile-camera/embed-photo parent-id) "camera")
       (mobile-bar-command commands/insert-youtube-timestamp "brand-youtube")
       (mobile-bar-command editor-handler/html-link-format! "link")
-      (mobile-bar-command-with-event history/undo! "rotate")
-      (mobile-bar-command-with-event history/redo! "rotate-clockwise")
+      (mobile-bar-command history/undo! "rotate" true)
+      (mobile-bar-command history/redo! "rotate-clockwise" true)
       (mobile-bar-command #(do (viewport-fn) (commands/simple-insert! parent-id "<" {})) "code")
       (mobile-bar-command editor-handler/bold-format! "bold")
       (mobile-bar-command editor-handler/italics-format! "italic")
@@ -301,7 +294,7 @@
    (fn [state]
      (mixins/on-key-down
       state
-      { ;; enter
+      {;; enter
        13 (fn [state e]
             (let [input-value (get state ::input-value)
                   input-option (get @state/state :editor/show-input)]
@@ -325,23 +318,23 @@
               [:input.form-input.block.w-full.pl-2.sm:text-sm.sm:leading-5
                (merge
                 (cond->
-                  {:key           (str "modal-input-" (name id))
-                   :id            (str "modal-input-" (name id))
-                   :type          (or type "text")
-                   :on-change     (fn [e]
-                                    (swap! input-value assoc id (util/evalue e)))
-                   :auto-complete (if (util/chrome?) "chrome-off" "off")}
+                 {:key           (str "modal-input-" (name id))
+                  :id            (str "modal-input-" (name id))
+                  :type          (or type "text")
+                  :on-change     (fn [e]
+                                   (swap! input-value assoc id (util/evalue e)))
+                  :auto-complete (if (util/chrome?) "chrome-off" "off")}
                   placeholder
                   (assoc :placeholder placeholder)
                   autoFocus
                   (assoc :auto-focus true))
                 (dissoc input-item :id))]])
            (ui/button
-             "Submit"
-             :on-click
-             (fn [e]
-               (util/stop e)
-               (on-submit command @input-value pos)))])))))
+            "Submit"
+            :on-click
+            (fn [e]
+              (util/stop e)
+              (on-submit command @input-value pos)))])))))
 
 (rum/defc absolute-modal < rum/static
   [cp set-default-width? {:keys [top left rect]}]
@@ -579,7 +572,8 @@
 
 (rum/defcs box < rum/reactive
   {:init (fn [state]
-           (assoc state ::heading-level (:heading-level (first (:rum/args state)))))
+           (assoc state ::heading-level (:heading-level (first (:rum/args state)))
+                  ::id (str (random-uuid))))
    :did-mount (fn [state]
                 (state/set-editor-args! (:rum/args state))
                 state)}

+ 1 - 0
src/main/frontend/components/editor.css

@@ -19,6 +19,7 @@
     align-items: center;
     overflow-x: overlay;
     overflow-y: hidden;
+    width: 95%;
   }
 
   .toolbar-hide-keyboard {

+ 130 - 80
src/main/frontend/components/header.cljs

@@ -1,26 +1,28 @@
 (ns frontend.components.header
-  (:require [frontend.components.export :as export]
+  (:require ["path" :as path]
+            [cljs-bean.core :as bean]
+            [frontend.components.export :as export]
+            [frontend.components.page-menu :as page-menu]
             [frontend.components.plugins :as plugins]
             [frontend.components.repo :as repo]
-            [frontend.components.page-menu :as page-menu]
             [frontend.components.right-sidebar :as sidebar]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
+            [frontend.fs.sync :as fs-sync]
             [frontend.handler :as handler]
-            [frontend.handler.page :as page-handler]
+            [frontend.handler.file-sync :as file-sync-handler]
             [frontend.handler.plugin :as plugin-handler]
-            [frontend.handler.user :as user-handler]
             [frontend.handler.route :as route-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.handler.web.nfs :as nfs]
-            [frontend.modules.shortcut.core :as shortcut]
+            [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
-            [cljs-bean.core :as bean]
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
-            [frontend.mobile.util :as mobile-util]))
+            [cljs.core.async :as a]))
 
 (rum/defc home-button []
   (ui/with-shortcut :go/home "left"
@@ -32,25 +34,95 @@
                    (route-handler/go-to-journals!))}
      (ui/icon "home" {:style {:fontSize ui/icon-size}})]))
 
-(rum/defc login
-  [logged?]
-  (when (and (not logged?)
-             (not config/publishing?))
+(rum/defc login < rum/reactive
+  []
+  (let [_ (state/sub :auth/id-token)]
+    (when-not config/publishing?
+      (if (user-handler/logged-in?)
+        [:span.text-sm.font-medium (user-handler/email)]
+
+        [:a.button.text-sm.font-medium.block {:on-click #(js/window.open config/LOGIN-URL)}
+         [:span (t :login)]]))))
+
+(rum/defcs file-sync-remote-graphs <
+  (rum/local nil ::remote-graphs)
+  [state]
+  (let [*remote-graphs (::remote-graphs state)
+        refresh-list-fn #(a/go (reset! *remote-graphs (a/<! (file-sync-handler/list-graphs))))]
+    (when (nil? @*remote-graphs)
+      ;; (println "call list-graphs api")
+      (refresh-list-fn))
+    [:div
+     [:div.flex
+      [:h1.title "Remote Graphs"]
+      [:div
+       {:on-click refresh-list-fn}
+       svg/refresh]]
+     [:p.text-sm "click to delete the selected graph"]
+     [:ul
+      (for [graph @*remote-graphs]
+        [:li.mb-4
+         [:a.font-medium
+          {:on-click #(do (println "delete graph" (:GraphName graph) (:GraphUUID graph))
+                          (file-sync-handler/delete-graph (:GraphUUID graph)))}
+          (:GraphName graph)]])]]))
+
+(rum/defcs file-sync <
+  rum/reactive
+  (rum/local nil ::existed-graphs)
+  [state]
+  (let [_ (state/sub :auth/id-token)
+        sync-state (state/sub :file-sync/sync-state)
+        not-syncing? (or (nil? sync-state) (fs-sync/sync-state--stopped? sync-state))
+        *existed-graphs (::existed-graphs state)
+        _ (rum/react file-sync-handler/refresh-file-sync-component)
+        graph-txid-exists? (file-sync-handler/graph-txid-exists?)
+        uploading-files (:current-local->remote-files sync-state)
+        downloading-files (:current-remote->local-files sync-state)]
+    (when-not config/publishing?
+      (when (user-handler/logged-in?)
+        (when-not (file-sync-handler/graph-txid-exists?)
+          (a/go (reset! *existed-graphs (a/<! (file-sync-handler/list-graphs)))))
+        (ui/dropdown-with-links
+         (fn [{:keys [toggle-fn]}]
+           (if not-syncing?
+             [:a.button
+              {:on-click toggle-fn}
+              (ui/icon "cloud-off" {:style {:fontSize ui/icon-size}})]
+             [:a.button
+              {:on-click toggle-fn}
+              (ui/icon "cloud" {:style {:fontSize ui/icon-size}})]))
+         (cond-> []
+           (not graph-txid-exists?)
+           (concat (->> @*existed-graphs
+                        (filterv #(and (:GraphName %) (:GraphUUID %)))
+                        (mapv (fn [graph]
+                                {:title (:GraphName graph)
+                                 :options {:on-click #(file-sync-handler/switch-graph (:GraphUUID graph))}})))
+                   [{:hr true}
+                    {:title "create graph"
+                     :options {:on-click #(file-sync-handler/create-graph (path/basename (state/get-current-repo)))}}])
+           graph-txid-exists?
+           (concat
+            [{:title "toggle file sync"
+              :options {:on-click #(if not-syncing? (fs-sync/sync-start) (fs-sync/sync-stop))}}
+             {:title "remote graph list"
+              :options {:on-click #(state/set-sub-modal! file-sync-remote-graphs)}}]
+            [{:hr true}]
+            (map (fn [f] {:title f
+                          :icon (ui/icon "arrow-narrow-up")}) uploading-files)
+            (map (fn [f] {:title f
+                          :icon (ui/icon "arrow-narrow-down")}) downloading-files)
+            (when sync-state
+              (map-indexed (fn [i f] (:time f)
+                     {:title [:div {:key i} [:div (:path f)] [:div.opacity-50 (util/time-ago (:time f))]]})
+                   (take 10 (:history sync-state))))))
+
+         (cond-> {}
+           (not graph-txid-exists?) (assoc :links-header [:div.font-medium.text-sm.opacity-60.px-4.pt-2
+                                                          "Switch to:"])))))))
+
 
-    (ui/dropdown-with-links
-     (fn [{:keys [toggle-fn]}]
-       [:a.button.text-sm.font-medium.block {:on-click toggle-fn}
-        [:span (t :login)]])
-     (let [list [{:title (t :login-github)
-                  :url (str config/website "/login/github")}]]
-       (mapv
-        (fn [{:keys [title url]}]
-          {:title title
-           :options
-           {:on-click
-            (fn [_] (set! (.-href js/window.location) url))}})
-        list))
-     nil)))
 
 (rum/defc left-menu-button < rum/reactive
   [{:keys [on-click]}]
@@ -62,8 +134,7 @@
 
 (rum/defc dropdown-menu < rum/reactive
   [{:keys [current-repo t]}]
-  (let [logged? (state/logged?)
-        page-menu (page-menu/page-menu nil)
+  (let [page-menu (page-menu/page-menu nil)
         page-menu-and-hr (when (seq page-menu)
                            (concat page-menu [{:hr true}]))]
     (ui/dropdown-with-links
@@ -72,7 +143,7 @@
         {:on-click toggle-fn}
         (ui/icon "dots" {:style {:fontSize ui/icon-size}})])
      (->>
-      [(when-not (state/publishing-enable-editing?)
+      [(when (state/enable-editing?)
          {:title (t :settings)
           :options {:on-click state/open-settings!}
           :icon (ui/icon "settings")})
@@ -92,7 +163,7 @@
           :options {:on-click #(state/set-modal! export/export)}
           :icon (ui/icon "database-export")})
 
-       (when current-repo
+       (when (and current-repo (state/enable-editing?))
          {:title (t :import)
           :options {:href (rfe/href :import)}
           :icon (ui/icon "file-upload")})
@@ -103,10 +174,11 @@
                   :title (t :discord-title)
                   :target "_blank"}
         :icon (ui/icon "brand-discord")}
-       (when logged?
-         {:title (t :sign-out)
-          :options {:on-click user-handler/sign-out!}
-          :icon svg/logout-sm})]
+       ;; (when logged?
+       ;;   {:title (t :sign-out)
+       ;;    :options {:on-click user-handler/sign-out!}
+       ;;    :icon svg/logout-sm})
+       ]
       (concat page-menu-and-hr)
       (remove nil?))
      {}
@@ -132,17 +204,17 @@
   [t]
   (let [[downloaded, set-downloaded] (rum/use-state nil)
         _ (rum/use-effect!
-            (fn []
-              (when-let [channel (and (util/electron?) "auto-updater-downloaded")]
-                (let [callback (fn [_ args]
-                                 (js/console.debug "[new-version downloaded] args:" args)
-                                 (let [args (bean/->clj args)]
-                                   (set-downloaded args)
-                                   (state/set-state! :electron/auto-updater-downloaded args))
-                                 nil)]
-                  (js/apis.addListener channel callback)
-                  #(js/apis.removeListener channel callback))))
-            [])]
+           (fn []
+             (when-let [channel (and (util/electron?) "auto-updater-downloaded")]
+               (let [callback (fn [_ args]
+                                (js/console.debug "[new-version downloaded] args:" args)
+                                (let [args (bean/->clj args)]
+                                  (set-downloaded args)
+                                  (state/set-state! :electron/auto-updater-downloaded args))
+                                nil)]
+                 (js/apis.addListener channel callback)
+                 #(js/apis.removeListener channel callback))))
+           [])]
 
     (when downloaded
       [:div.cp__header-tips
@@ -151,8 +223,8 @@
          {:on-click #(handler/quit-and-install-new-version!)}
          (svg/reload 16) [:strong (t :updater/quit-and-install)]]]])))
 
-(rum/defc header < rum/reactive
-  [{:keys [open-fn current-repo logged? me default-home new-block-mode]}]
+(rum/defc ^:large-vars/cleanup-todo header < rum/reactive
+  [{:keys [open-fn current-repo default-home new-block-mode]}]
   (let [repos (->> (state/sub [:me :repos])
                    (remove #(= (:url %) config/local-repo)))
         electron-mac? (and util/mac? (util/electron?))
@@ -161,8 +233,7 @@
                                (or (empty? repos)
                                    (nil? (state/sub :git/current-repo)))
                                (not (mobile-util/is-native-platform?))
-                               (not config/publishing?))
-        refreshing? (state/sub :nfs/refreshing?)]
+                               (not config/publishing?))]
     [:div.cp__header#head
      {:class           (util/classnames [{:electron-mac   electron-mac?
                                           :native-ios     (mobile-util/native-ios?)
@@ -190,10 +261,10 @@
            (ui/icon "search" {:style {:fontSize ui/icon-size}})]))]
 
      [:div.r.flex
-      (when (and (not (mobile-util/is-native-platform?))
-                 (not (util/electron?)))
-        (login logged?))
-
+      (when-not file-sync-handler/hiding-login&file-sync
+        (file-sync))
+      (when-not file-sync-handler/hiding-login&file-sync
+        (login))
       (when plugin-handler/lsp-enabled?
         (plugins/hook-ui-items :toolbar))
 
@@ -206,42 +277,21 @@
 
       (new-block-mode)
 
-      (when (and (mobile-util/is-native-platform?) (seq repos))
-        [:a.text-sm.font-medium.button
-         {:on-click
-          (fn []
-            (state/pub-event!
-             [:modal/show
-              [:div {:style {:max-width 700}}
-               [:p "Refresh detects and processes files modified on your disk and diverged from the actual Logseq page content. Continue?"]
-               (ui/button
-                 "Yes"
-                 :on-click (fn []
-                             (state/close-modal!)
-                             (nfs/refresh! (state/get-current-repo) repo/refresh-cb)))]]))}
-         (if refreshing?
-           [:div {:class "animate-spin-reverse"}
-            svg/refresh]
-           [:div.flex.flex-row.text-center.open-button__inner.items-center
-            (ui/icon "refresh" {:style {:fontSize ui/icon-size}})])])
-
       (repo/sync-status current-repo)
 
       (when show-open-folder?
-        [:a.text-sm.font-medium.button
-         {:on-click #(page-handler/ls-dir-files! shortcut/refresh!)}
-         [:div.flex.flex-row.text-center.open-button__inner.items-center
-          (ui/icon "folder-plus")
-          (when-not config/mobile?
-            [:span.ml-1 {:style {:margin-top (if electron-mac? 0 2)}}
-             (t :open)])]])
+        [:a.text-sm.font-medium.button.add-graph-btn.flex.items-center
+         {:on-click #(route-handler/redirect! {:to :repo-add})}
+         (ui/icon "folder-plus")
+         (when-not config/mobile?
+           [:strong {:style {:margin-top (if electron-mac? 0 2)}}
+            (t :on-boarding/add-graph)])])
 
       (when config/publishing?
         [:a.text-sm.font-medium.button {:href (rfe/href :graph)}
          (t :graph)])
 
-      (dropdown-menu {:me           me
-                      :t            t
+      (dropdown-menu {:t            t
                       :current-repo current-repo
                       :default-home default-home})
 

+ 31 - 13
src/main/frontend/components/header.css

@@ -1,7 +1,7 @@
 .cp__header {
   @apply shadow z-10;
   -webkit-app-region: drag;
-  
+
   padding-top: var(--ls-headbar-inner-top-padding);
   height: calc(var(--ls-headbar-height) + var(--ls-headbar-inner-top-padding));
   display: flex;
@@ -32,9 +32,10 @@
   }
 
   /* To prevent header glitch on Safari */
+
   > .l, > .r {
-      -webkit-transform: translate3d(0, 0, 0);
-      transform: translate3d(0, 0, 0);
+    -webkit-transform: translate3d(0, 0, 0);
+    transform: translate3d(0, 0, 0);
   }
 
   .it svg {
@@ -110,6 +111,17 @@
     }
   }
 
+  .add-graph-btn {
+    border: 1px dashed var(--ls-border-color);
+    padding: 0 8px;
+
+    strong {
+      padding: 0 10px;
+      position: relative;
+      top: 1px;
+    }
+  }
+
   .doc-modes {
     .non-editing {
       position: relative;
@@ -193,14 +205,20 @@ a.button {
   opacity: 0.6;
   display: block;
   border-radius: 4px;
+  user-select: none;
 
   &:hover, &.active {
     opacity: 1;
     background: none;
+
     @screen md {
         background: var(--ls-tertiary-background-color);
     }
   }
+
+  &:active {
+    opacity: .7;
+  }
 }
 
 .is-mac.is-electron :is(.cp__header, .cp__right-sidebar-topbar) :is(button, .button, a) {
@@ -208,7 +226,7 @@ a.button {
 }
 
 html.is-ios.is-safari {
-    
+
   .cp__header {
     background-color: var(--ls-primary-background-color);
   }
@@ -230,7 +248,7 @@ html.is-native-ipad {
         padding-top: 0px;
         height: calc(100vh - var(--ls-headbar-inner-top-padding) - var(--ls-headbar-height));
     }
-    
+
     .cp__header > .r {
         display: flex;
     }
@@ -239,11 +257,11 @@ html.is-native-ipad {
 html.is-native-ipad {
     --ls-headbar-inner-top-padding: 0px;
     --ls-headbar-height: 4rem;
-    
+
     .cp__header {
       background-color: transparent !important;
       display: flex;
-      
+
       > .l {
         /* background-color: var(--ls-primary-background-color); */
         padding-top: 20px;
@@ -258,7 +276,7 @@ html.is-native-ipad {
         align-items: center;
       }
     }
-    
+
     .left-sidebar-inner  {
         > .wrap {
             padding-top: 20px;
@@ -280,7 +298,7 @@ html.is-native-ipad {
                 width: 12px;
                 height: 40vh;
             }
-            
+
             .resizer:hover {
                 background-color: var(--ls-guideline-color, #ddd);
             }
@@ -300,11 +318,11 @@ html.is-native-iphone {
             padding-bottom: 12px;
         }
     }
-    
+
     .ui__notifications {
         top: calc(var(--ls-headbar-height) + var(--ls-headbar-inner-top-padding) - 0.3rem);
     }
-    
+
     @media (orientation: landscape) {
         --ls-headbar-inner-top-padding: 8px;
         --ls-headbar-height: 2.5rem;
@@ -316,7 +334,7 @@ html.is-native-iphone {
 }
 
 html.is-native-iphone-without-notch {
-    
+
     --ls-headbar-inner-top-padding: 15px;
     --ls-headbar-height: 2.5rem;
 
@@ -324,7 +342,7 @@ html.is-native-iphone-without-notch {
 
         --ls-headbar-inner-top-padding: 0px;
         --ls-headbar-height: 2.5rem;
-     
+
         .cp__header {
             @apply shadow z-10;
         }

+ 38 - 60
src/main/frontend/components/journal.cljs

@@ -1,10 +1,7 @@
 (ns frontend.components.journal
   (:require [clojure.string :as string]
-            [frontend.components.onboarding :as onboarding]
             [frontend.components.page :as page]
             [frontend.components.reference :as reference]
-            [frontend.components.widgets :as widgets]
-            [frontend.config :as config]
             [frontend.date :as date]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
@@ -16,10 +13,7 @@
             [frontend.util :as util]
             [goog.object :as gobj]
             [reitit.frontend.easy :as rfe]
-            [rum.core :as rum]
-            [frontend.mobile.util :as mobile-util]
-            [frontend.modules.shortcut.core :as shortcut]
-            [frontend.context.i18n :refer [t]]))
+            [rum.core :as rum]))
 
 (rum/defc blocks-cp < rum/reactive db-mixins/query
   {}
@@ -34,72 +28,56 @@
         repo (state/sub :git/current-repo)
         today? (= (string/lower-case title)
                   (string/lower-case (date/journal-name)))
-        intro? (and (not (state/logged?))
-                    (not (config/local-db? repo))
-                    (not config/publishing?)
-                    today?)
         page-entity (db/pull [:block/name (util/page-name-sanity-lc title)])
         data-page-tags (when (seq (:block/tags page-entity))
                          (let [page-names (model/get-page-names-by-ids (map :db/id (:block/tags page)))]
                            (text/build-data-value page-names)))]
-    (if (and (mobile-util/is-native-platform?) intro?)
-      [:div
-       [:h1.title
-        (t :new-graph)]
-
-       (ui/button
-         (t :open-a-directory)
-         :on-click #(page-handler/ls-dir-files! shortcut/refresh!))]
-      [:div.flex-1.journal.page (cond->
-                                  {:class (if intro? "logseq-intro" "")}
-                                  data-page-tags
-                                  (assoc :data-page-tags data-page-tags))
-
-       (ui/foldable
-        [:a.initial-color.title.journal-title
-         {:href     (rfe/href :page {:name page})
-          :on-mouse-down (fn [e]
-                           (when (util/right-click? e)
-                             (state/set-state! :page-title/context {:page page})))
-          :on-click (fn [e]
-                      (when (gobj/get e "shiftKey")
-                        (when-let [page page-entity]
-                          (state/sidebar-add-block!
-                           (state/get-current-repo)
-                           (:db/id page)
-                           :page
-                           {:page     page
-                            :journal? true}))
-                        (.preventDefault e)))}
-         [:h1.title
-          (util/capitalize-all title)]]
+    [:div.flex-1.journal.page (cond-> {}
+                                data-page-tags
+                                (assoc :data-page-tags data-page-tags))
 
-        (blocks-cp repo page format)
-
-        {})
+     (ui/foldable
+      [:a.initial-color.title.journal-title
+       {:href     (rfe/href :page {:name page})
+        :on-mouse-down (fn [e]
+                         (when (util/right-click? e)
+                           (state/set-state! :page-title/context {:page page})))
+        :on-click (fn [e]
+                    (when (gobj/get e "shiftKey")
+                      (when-let [page page-entity]
+                        (state/sidebar-add-block!
+                         (state/get-current-repo)
+                         (:db/id page)
+                         :page
+                         {:page     page
+                          :journal? true}))
+                      (.preventDefault e)))}
+       [:h1.title
+        (util/capitalize-all title)]]
 
-       (when intro? (widgets/add-graph))
+      (blocks-cp repo page format)
 
-       (page/today-queries repo today? false)
+      {})
 
-       (rum/with-key
-         (reference/references title false)
-         (str title "-refs"))
+     (page/today-queries repo today? false)
 
-       (when intro? (onboarding/intro))])))
+     (rum/with-key
+       (reference/references title)
+       (str title "-refs"))]))
 
 (rum/defc journals < rum/reactive
   [latest-journals]
   [:div#journals
-   (ui/infinite-list "main-content-container"
-                     (for [{:block/keys [name format]} latest-journals]
-                       [:div.journal-item.content {:key name}
-                        (journal-cp [name format])])
-                     {:has-more (page-handler/has-more-journals?)
-                      :more-class "text-4xl"
-                      :on-top-reached page-handler/create-today-journal!
-                      :on-load (fn []
-                                 (page-handler/load-more-journals!))})])
+   (ui/infinite-list
+    "main-content-container"
+    (for [{:block/keys [name format]} latest-journals]
+      [:div.journal-item.content {:key name}
+       (journal-cp [name format])])
+    {:has-more (page-handler/has-more-journals?)
+     :more-class "text-4xl"
+     :on-top-reached page-handler/create-today-journal!
+     :on-load (fn []
+                (page-handler/load-more-journals!))})])
 
 (rum/defc all-journals < rum/reactive db-mixins/query
   []

+ 5 - 165
src/main/frontend/components/onboarding.cljs

@@ -1,173 +1,13 @@
 (ns frontend.components.onboarding
-  (:require [frontend.components.svg :as svg]
-            [frontend.context.i18n :refer [t]]
+  (:require [frontend.context.i18n :refer [t]]
             [frontend.handler.route :as route-handler]
             [rum.core :as rum]
-            [frontend.ui :as ui]))
+            [frontend.ui :as ui]
+            [frontend.components.onboarding.setups :as setups]))
 
-(rum/defc ^:large-vars/cleanup-todo intro
+(rum/defc intro
   []
-  [:div#logseq-intro.pl-1
-   [:div.flex-1
-    [:div.flex.flex-col.pl-1.ls-block
-     [:hr {:style {:margin-top 200}}]
-     [:div.flex.flex-row.admonitionblock.align-items {:class "important"}
-      [:div.pr-4.admonition-icon.flex.flex-col.justify-center
-       {:title "Important"} (svg/tip)]
-      [:div.ml-4.text-lg
-       (t :on-boarding/notice)]]
-     [:p
-      {}
-      (t :on-boarding/features-desc)]
-     [:p
-      {}
-      (t :on-boarding/privacy)]
-     [:p
-      {}
-      [:strong {} "Logseq"]
-      (t :on-boarding/inspired-by)
-      [:a {:href "https://roamresearch.com/"
-           :target "_blank"} "Roam Research"]
-      ", "
-      [:a {:href "https://orgmode.org/"
-           :target "_blank"} "Org Mode"]
-      ", "
-      [:a {:href "https://tiddlywiki.com/"
-           :target "_blank"} "Tiddlywiki"]
-      " and "
-      [:a {:href "https://workflowy.com/"
-           :target "_blank"} "Workflowy"]
-      ", hats off to all of them!"]]
-
-    [:img.shadow-2xl
-     {:src
-      "https://asset.logseq.com/static/img/screenshot.png"
-      :alt "screenshot"}]
-
-    [:div.flex.flex-col.ls-block.intro-docs
-     [:h2 {} (t :on-boarding/features)]
-     [:ul
-      {}
-      [:li {} (t :on-boarding/features-backlinks)]
-      [:li {} (t :on-boarding/features-block-embed)]
-      [:li {} (t :on-boarding/features-page-embed)]
-      [:li {} (t :on-boarding/features-graph-vis)]
-      [:li {} "PDF annotations"]
-      [:li {} "Zotero integration"]
-      [:li {} "Spaced repetition cards"]
-      [:li {} (t :on-boarding/features-heading-properties)]
-      [:li
-       {}
-       (t :on-boarding/features-datalog)
-       [:a {:href "https://github.com/tonsky/datascript"
-            :target "_blank"} "Datascript"]]
-      [:li {} (t :on-boarding/features-custom-view-component)]
-      [:li
-       {}
-       [:a {:href "https://excalidraw.com/"
-            :target "_blank"} "Excalidraw"]
-       (t :on-boarding/integration)]
-      [:li
-       {}
-       [:a {:href "https://revealjs.com/"
-            :target "_blank"} "reveal.js"]
-       (t :on-boarding/slide-support)]
-      [:li
-       {}
-       (t :on-boarding/built-in-supports)
-       [:ul
-        {}
-        [:li {} (t :on-boarding/supports-code-highlights)]
-        [:li {} (t :on-boarding/supports-katex-latex)]
-        [:li
-         {}
-         (t :on-boarding/raw)
-         [:a {:href "https://github.com/weavejester/hiccup"
-              :target "_blank"} "hiccup"]]
-        [:li {} (t :on-boarding/raw-html)]]]]
-     [:h2 {} (t :on-boarding/learn-more)]
-     [:ul
-      {}
-      [:li
-       {}
-       "Twitter: "
-       [:a
-        {:href "https://twitter.com/logseq"
-         :target "_blank"}
-        "https://twitter.com/logseq"]]
-      [:li
-       {}
-       "Discord: "
-       [:a
-        {:href "https://discord.gg/KpN4eHY"
-         :target "_blank"}
-        "https://discord.gg/KpN4eHY"]
-       (t :on-boarding/discord-desc)]
-      [:li
-       {}
-       "GitHub: "
-       [:a
-        {:href "https://github.com/logseq/logseq"
-         :target "_blank"}
-        "https://github.com/logseq/logseq"]
-       (t :on-boarding/github-desc)]
-      [:li
-       {}
-       (t :on-boarding/our-blog)
-       [:a
-        {:href "https://docs.logseq.com/"
-         :target "_blank"}
-        "https://docs.logseq.com/"]]]
-     [:h2 (t :on-boarding/credits-to)]
-     [:ul
-      {}
-      [:li [:a {:href "https://roamresearch.com/"
-                :target "_blank"} "Roam Research"]]
-      [:li [:a {:href "https://orgmode.org/"
-                :target "_blank"} "Org Mode"]]
-      [:li [:a {:href "https://tiddlywiki.com/"
-                :target "_blank"} "Tiddlywiki"]]
-      [:li
-       [:a {:href "https://workflowy.com/"
-            :target "_blank"} "Workflowy"]]
-      [:li
-       [:a
-        {:href "https://clojure.org"
-         :target "_blank"}
-        "Clojure && Clojurescript"]
-       (t :on-boarding/clojure-desc)]
-      [:li
-       [:a {:href "https://github.com/tonsky/datascript"
-            :target "_blank"} "Datascript"]
-       (t :on-boarding/datascript-desc)]
-      [:li
-       [:a {:href "https://ocaml.org/"
-            :target "_blank"} "OCaml"]
-       " && "
-       [:a
-        {:href "https://github.com/inhabitedtype/angstrom"
-         :target "_blank"}
-        "Angstrom"]
-       (t :on-boarding/angstrom-desc-1)
-       [:a {:href "https://github.com/mldoc/mldoc"
-            :target "_blank"} (t :on-boarding/angstrom-desc-2)]
-       (t :on-boarding/angstrom-desc-3)]
-      [:li
-       [:a {:href "https://github.com/talex5/cuekeeper"
-            :target "_blank"} "Cuekeeper"]
-       (t :on-boarding/cuekeeper-desc)]
-      [:li
-       [:a {:href "https://github.com/borkdude/sci"
-            :target "_blank"} "sci"]
-       (t :on-boarding/sci-desc)]
-      [:li
-       [:a {:href "https://isomorphic-git.org/"
-            :target "_blank"} "isomorphic-git"]
-       (t :on-boarding/isomorphic-git-desc)]]
-
-     [:img {:src
-            "https://asset.logseq.com/static/img/credits.png"
-            :style {:margin "12px 0 0 0"}}]]]])
+  (setups/picker))
 
 (defn help
   []

+ 0 - 35
src/main/frontend/components/onboarding.css

@@ -1,38 +1,3 @@
-#logseq-intro {
-  h1,
-  h2 {
-    margin: 2.5em 0 0.5em;
-  }
-
-  h2 {
-    font-size: 1.4em;
-  }
-
-  h3 {
-    font-size: 1.275em;
-    margin: 1.5em 0 0.5em;
-  }
-
-  h4 {
-    font-size: 1.175em;
-    margin: 1em 0 0.5em;
-  }
-
-  img {
-    margin: 5em 0;
-    max-width: 100%;
-  }
-
-  p {
-    margin: 15px 0;
-  }
-
-  .content {
-    flex-direction: column;
-    align-items: center;
-  }
-}
-
 .intro-docs {
   max-width: var(--ls-main-content-max-width, 100%)
 }

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott