ソースを参照

Merge branch 'master' into feat/publish

Tienson Qin 2 年 前
コミット
111de49509
100 ファイル変更3106 行追加1225 行削除
  1. 5 2
      .clj-kondo/config.edn
  2. 15 0
      .clj-kondo/hooks/path_invalid_construct.clj
  3. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  4. 9 9
      .github/workflows/build-android.yml
  5. 52 52
      .github/workflows/build-desktop-release.yml
  6. 1 1
      .github/workflows/build-docker.yml
  7. 1 1
      .github/workflows/build-ios-release.yml
  8. 7 7
      .github/workflows/build-ios.yml
  9. 2 2
      .github/workflows/build-stage.yml
  10. 15 7
      .github/workflows/build.yml
  11. 5 6
      .github/workflows/db.yml
  12. 9 11
      .github/workflows/e2e.yml
  13. 3 3
      .github/workflows/graph-parser.yml
  14. 1 1
      .gitignore
  15. 2 0
      .lsp/config.edn
  16. 174 0
      CONTRIBUTING.md
  17. 3 3
      Dockerfile
  18. 172 68
      README.md
  19. 2 2
      android/app/build.gradle
  20. 5 2
      bb.edn
  21. 2 1
      deps.edn
  22. 1 1
      deps/db/README.md
  23. 12 2
      deps/db/src/logseq/db/default.cljs
  24. 2 2
      deps/db/src/logseq/db/rules.cljc
  25. 1 0
      deps/db/src/logseq/db/schema.cljs
  26. 4 0
      deps/graph-parser/.carve/ignore
  27. 1 1
      deps/graph-parser/README.md
  28. 79 34
      deps/graph-parser/src/logseq/graph_parser/block.cljs
  29. 1 3
      deps/graph-parser/src/logseq/graph_parser/extract.cljc
  30. 13 1
      deps/graph-parser/src/logseq/graph_parser/mldoc.cljc
  31. 220 0
      deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc
  32. 10 9
      deps/graph-parser/src/logseq/graph_parser/util.cljs
  33. 81 0
      deps/graph-parser/test/logseq/graph_parser/block_test.cljs
  34. 7 0
      deps/graph-parser/test/logseq/graph_parser/util_test.cljs
  35. 46 0
      deps/graph-parser/test/logseq/graph_parser_test.cljs
  36. 1 1
      docs/accessibility.md
  37. 3 3
      docs/contributing-to-translations.md
  38. 16 0
      docs/dev-practices.md
  39. 6 6
      docs/develop-logseq-on-windows.md
  40. 15 0
      docs/develop-logseq.md
  41. 122 122
      docs/issue-labels.md
  42. 3 2
      e2e-tests/basic.spec.ts
  43. 1 1
      e2e-tests/code-editing.spec.ts
  44. 28 107
      e2e-tests/editor.spec.ts
  45. 6 14
      e2e-tests/fixtures.ts
  46. 138 0
      e2e-tests/fs.spec.ts
  47. 1 0
      e2e-tests/headings.spec.ts
  48. 4 12
      e2e-tests/hotkey.spec.ts
  49. 2 2
      e2e-tests/logseq-url.spec.ts
  50. 30 54
      e2e-tests/page-search.spec.ts
  51. 1 1
      e2e-tests/random.spec.ts
  52. 2 0
      e2e-tests/sidebar.spec.ts
  53. 42 0
      e2e-tests/util/basic.ts
  54. 63 0
      e2e-tests/util/search-modal.ts
  55. 15 85
      e2e-tests/utils.ts
  56. 149 16
      e2e-tests/whiteboards.spec.ts
  57. 4 4
      ios/App/App.xcodeproj/project.pbxproj
  58. 5 5
      ios/App/App/FsWatcher.swift
  59. 21 18
      libs/src/LSPlugin.core.ts
  60. 1 1
      libs/src/LSPlugin.ts
  61. 2 2
      package.json
  62. 13 6
      resources/css/common.css
  63. 36 0
      resources/css/show-hint.css
  64. 3 3
      resources/forge.config.js
  65. 0 0
      resources/js/lsplugin.core.js
  66. 3 2
      resources/package.json
  67. 1 1
      scripts/src/logseq/tasks/dev.clj
  68. 1 1
      scripts/src/logseq/tasks/file_sync.clj
  69. 66 5
      scripts/src/logseq/tasks/lang.clj
  70. 27 0
      scripts/src/logseq/tasks/malli.clj
  71. 6 7
      src/electron/electron/context_menu.cljs
  72. 5 5
      src/electron/electron/core.cljs
  73. 7 1
      src/electron/electron/git.cljs
  74. 5 2
      src/electron/electron/handler.cljs
  75. 19 19
      src/electron/electron/search.cljs
  76. 10 4
      src/electron/electron/server.cljs
  77. 2 2
      src/electron/electron/url.cljs
  78. 10 10
      src/electron/electron/utils.cljs
  79. 72 37
      src/electron/electron/window.cljs
  80. 5 1
      src/main/frontend/commands.cljs
  81. 326 274
      src/main/frontend/components/block.cljs
  82. 6 0
      src/main/frontend/components/block.css
  83. 64 32
      src/main/frontend/components/commit.cljs
  84. 5 5
      src/main/frontend/components/content.cljs
  85. 3 3
      src/main/frontend/components/datetime.cljs
  86. 25 16
      src/main/frontend/components/editor.cljs
  87. 7 0
      src/main/frontend/components/editor.css
  88. 130 52
      src/main/frontend/components/export.cljs
  89. 52 18
      src/main/frontend/components/file.cljs
  90. 4 4
      src/main/frontend/components/header.cljs
  91. 1 1
      src/main/frontend/components/hierarchy.cljs
  92. 3 3
      src/main/frontend/components/onboarding.cljs
  93. 10 11
      src/main/frontend/components/page.cljs
  94. 2 4
      src/main/frontend/components/page_menu.cljs
  95. 2 1
      src/main/frontend/components/plugins.cljs
  96. 8 1
      src/main/frontend/components/plugins.css
  97. 4 3
      src/main/frontend/components/plugins_settings.cljs
  98. 463 0
      src/main/frontend/components/query/builder.cljs
  99. 46 0
      src/main/frontend/components/query/builder.css
  100. 9 3
      src/main/frontend/components/query_table.cljs

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

@@ -10,7 +10,8 @@
                          {:exclude [frontend.db.conn frontend.db.react logseq.db.default]}}}}
 
  :linters
- {:aliased-namespace-symbol {:level :warning}
+ {:path-invalid-construct/string-join {:level :info}
+  :aliased-namespace-symbol {:level :warning}
   ;; Disable until it doesn't trigger false positives on rum/defcontext
   :earmuffed-var-not-dynamic {:level :off}
   :unresolved-symbol {:exclude [goog.DEBUG
@@ -67,6 +68,7 @@
              frontend.handler.page page-handler
              frontend.handler.plugin plugin-handler
              frontend.handler.plugin-config plugin-config-handler
+             frontend.handler.query.builder query-builder
              frontend.handler.repo repo-handler
              frontend.handler.repo-config repo-config-handler
              frontend.handler.route route-handler
@@ -108,7 +110,8 @@
   :used-underscored-binding {:level :warning}}
 
  :hooks {:analyze-call {rum.core/defc hooks.rum/defc
-                         rum.core/defcs hooks.rum/defcs}}
+                        rum.core/defcs hooks.rum/defcs
+                        clojure.string/join hooks.path-invalid-construct/string-join}}
  :lint-as {promesa.core/let clojure.core/let
            promesa.core/loop clojure.core/loop
            promesa.core/recur clojure.core/recur

+ 15 - 0
.clj-kondo/hooks/path_invalid_construct.clj

@@ -0,0 +1,15 @@
+(ns hooks.path-invalid-construct
+  "This hook try to find out those error-prone path construction expressions:
+  - (string/join \"/\" [...])"
+  (:require [clj-kondo.hooks-api :as api]))
+
+
+(defn string-join
+  [{:keys [node]}]
+  (let [[_ sep-v & _args] (:children node)]
+    ;; (prn :string-join)
+    (when (and (api/string-node? sep-v)
+               (= ["/"] (:lines sep-v)))
+      (api/reg-finding! (assoc (meta node)
+                               :message "don't use clojure.string/join to build a path, (use #_{:clj-kondo/ignore [:path-invalid-construct/string-join]} to ignore)"
+                               :type :path-invalid-construct/string-join)))))

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -80,7 +80,7 @@ body:
       label: Are you willing to submit a PR? If you know how to fix the bug.
       description: |
         If you are not familiar with programming, you can skip this step.
-        If you are a developer and know how to fix the bug, you can submit a PR to fix it. You can find the [contributing guide](https://github.com/logseq/logseq#how-to-contribute-with-a-pr) here.
+        If you are a developer and know how to fix the bug, you can [submit a PR to fix it](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md#submit-pr).
         Your contributions are greatly appreciated and play a vital role in helping to improve the project!
       options:
         - label: I'm willing to submit a PR (Thank you!)

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

@@ -42,7 +42,7 @@ on:
 
 env:
   CLOJURE_VERSION: '1.10.1.763'
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   JAVA_VERSION: '11'
 
 jobs:
@@ -55,16 +55,16 @@ jobs:
           ref: ${{ github.event.inputs.git-ref }}
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       - name: Get yarn cache directory path
         id: yarn-cache-dir-path
-        run: echo "::set-output name=dir::$(yarn cache dir)"
+        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache yarn cache directory
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: yarn-cache
         with:
           path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -73,13 +73,13 @@ jobs:
             ${{ runner.os }}-yarn-
 
       - name: Setup Java JDK
-        uses: actions/setup-java@v2
+        uses: actions/setup-java@v3
         with:
           distribution: 'zulu'
           java-version: ${{ env.JAVA_VERSION }}
 
       - name: Cache clojure deps
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             ~/.m2/repository
@@ -87,7 +87,7 @@ jobs:
           key: ${{ runner.os }}-clojure-lib-${{ hashFiles('**/deps.edn') }}
 
       - name: Setup clojure
-        uses: DeLaGuardo/setup-clojure@3.5
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
 
@@ -95,7 +95,7 @@ jobs:
         id: ref
         run: |
           pkgver=$(node ./scripts/get-pkg-version.js "${{ inputs.build-target || github.event.inputs.build-target }}")
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: Update Nightly APP Version
         if: ${{ inputs.build-target == '' || inputs.build-target == 'nightly' || github.event.inputs.build-target == 'nightly' }}
@@ -162,7 +162,7 @@ jobs:
           mv android/app-signed.apk ./builds/Logseq-android-${{ steps.ref.outputs.version }}.apk
 
       - name: Upload Artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: logseq-android-builds
           path: builds

+ 52 - 52
.github/workflows/build-desktop-release.yml

@@ -48,11 +48,12 @@ on:
 
 env:
   CLOJURE_VERSION: '1.10.1.763'
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
+  JAVA_VERSION: '11'
 
 jobs:
   compile-cljs:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-20.04
     steps:
       - name: Check build options
         if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.build-target == 'nightly' || github.event.inputs.build-target == 'beta') && github.event.inputs.git-ref != 'master' }}
@@ -61,21 +62,21 @@ jobs:
           exit 1
 
       - name: Check out Git repository
-        uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c  # v3.3.0
+        uses: actions/checkout@v3
         with:
           ref: ${{ github.event.inputs.git-ref }}
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       - name: Get yarn cache directory path
         id: yarn-cache-dir-path
-        run: echo "::set-output name=dir::$(yarn cache dir)"
+        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache yarn cache directory
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: yarn-cache
         with:
           path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -84,12 +85,13 @@ jobs:
             ${{ runner.os }}-yarn-
 
       - name: Setup Java JDK
-        uses: actions/setup-java@v1.4.3
+        uses: actions/setup-java@v3
         with:
-          java-version: 1.8
+          distribution: 'zulu'
+          java-version: ${{ env.JAVA_VERSION }}
 
       - name: Cache clojure deps
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             ~/.m2/repository
@@ -97,7 +99,7 @@ jobs:
           key: ${{ runner.os }}-clojure-lib-${{ hashFiles('**/deps.edn') }}
 
       - name: Setup clojure
-        uses: DeLaGuardo/setup-clojure@3.5
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
 
@@ -105,7 +107,7 @@ jobs:
         id: ref
         run: |
           pkgver=$(node ./scripts/get-pkg-version.js "${{ github.event.inputs.build-target }}")
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: Do Not Overwrite Existing Release
         if: ${{ github.event.inputs.build-target == 'beta' }}
@@ -164,17 +166,17 @@ jobs:
           SENTRY_PROJECT: logseq
 
       - name: Cache Static File
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: static
           path: static
 
   build-linux:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-20.04
     needs: [ compile-cljs ]
     steps:
       - name: Download The Static Asset
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: static
           path: static
@@ -183,15 +185,15 @@ jobs:
         id: ref
         run: |
           pkgver=$(cat ./static/VERSION)
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       # - name: Cache Node Modules
-      #   uses: actions/cache@v2
+      #   uses: actions/cache@v3
       #   with:
       #     path: |
       #       **/node_modules
@@ -210,7 +212,7 @@ jobs:
           mv static/out/make/zip/linux/x64/*-linux-x64-*.zip ./builds/Logseq-linux-x64-${{ steps.ref.outputs.version }}.zip
 
       - name: Upload Artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: logseq-linux-builds
           path: builds
@@ -220,24 +222,22 @@ jobs:
     needs: [ compile-cljs ]
     steps:
       - name: Download The Static Asset
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: static
           path: static
 
       - name: Retrieve tag version
         id: ref
-        run: |
-          $env:PkgVer=$(cat ./static/VERSION)
-          echo "::set-output name=version::$env:PkgVer"
+        run: echo "version=$(cat ./static/VERSION)" >> $env:GITHUB_OUTPUT
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       # - name: Cache Node Modules
-      #   uses: actions/cache@v2
+      #   uses: actions/cache@v3
       #   with:
       #    path: |
       #      **/node_modules
@@ -273,7 +273,7 @@ jobs:
           mv static\out\make\squirrel.windows\x64\RELEASES builds\RELEASES
 
       - name: Upload Artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: logseq-win64-builds
           path: builds
@@ -284,7 +284,7 @@ jobs:
 
     steps:
       - name: Download The Static Asset
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: static
           path: static
@@ -293,21 +293,21 @@ jobs:
         id: ref
         run: |
           pkgver=$(cat ./static/VERSION)
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: List Static Files
         run: ls -al ./static
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       - name: Get yarn cache directory path
         id: yarn-cache-dir-path
-        run: echo "::set-output name=dir::$(yarn cache dir)"
+        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
       - name: Cache yarn cache directory
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: yarn-cache
         with:
           path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -323,7 +323,7 @@ jobs:
           p12-password: ${{ secrets.APPLE_CERTIFICATES_P12_PASSWORD }}
 
       # - name: Cache Node Modules
-      #   uses: actions/cache@v2
+      #   uses: actions/cache@v3
       #   with:
       #     path: |
       #       **/node_modules
@@ -344,7 +344,7 @@ jobs:
           mv static/out/make/zip/darwin/x64/*.zip ./builds/Logseq-darwin-x64-${{ steps.ref.outputs.version }}.zip
 
       - name: Upload Artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: logseq-darwin-x64-builds
           path: builds
@@ -355,7 +355,7 @@ jobs:
 
     steps:
       - name: Download The Static Asset
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: static
           path: static
@@ -364,18 +364,18 @@ jobs:
         id: ref
         run: |
           pkgver=$(cat ./static/VERSION)
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       - name: Get yarn cache directory path
         id: yarn-cache-dir-path
-        run: echo "::set-output name=dir::$(yarn cache dir)"
+        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
       - name: Cache yarn cache directory
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: yarn-cache
         with:
           path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -391,7 +391,7 @@ jobs:
           p12-password: ${{ secrets.APPLE_CERTIFICATES_P12_PASSWORD }}
 
       # - name: Cache Node Modules
-      #   uses: actions/cache@v2
+      #   uses: actions/cache@v3
       #   with:
       #     path: |
       #       **/node_modules
@@ -416,7 +416,7 @@ jobs:
           mv static/out/make/zip/darwin/arm64/*.zip ./builds/Logseq-darwin-arm64-${{ steps.ref.outputs.version }}.zip
 
       - name: Upload Artifact
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: logseq-darwin-arm64-builds
           path: builds
@@ -437,34 +437,34 @@ jobs:
   nightly-release:
     if: ${{ github.event_name == 'schedule' || github.event.inputs.build-target == 'nightly' }}
     needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows, build-android ]
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-20.04
     steps:
       - name: Download MacOS x64 Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-darwin-x64-builds
           path: ./
 
       - name: Download MacOS arm64 Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-darwin-arm64-builds
           path: ./
 
       - name: Download The Linux Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-linux-builds
           path: ./
 
       - name: Download The Windows Artifact
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-win64-builds
           path: ./
 
       - name: Download Android Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-android-builds
           path: ./
@@ -504,34 +504,34 @@ jobs:
     # NOTE: For now, we only have beta channel to be released on Github
     if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build-target == 'beta' }}
     needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows ]
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-20.04
     steps:
       - name: Download MacOS x64 Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-darwin-x64-builds
           path: ./
 
       - name: Download MacOS arm64 Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-darwin-arm64-builds
           path: ./
 
       - name: Download The Linux Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-linux-builds
           path: ./
 
       - name: Download The Windows Artifact
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: logseq-win64-builds
           path: ./
 
       - name: Download Android Artifacts
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         if: ${{ github.event_name == 'schedule' || github.event.inputs.build-android == 'true' }}
         with:
           name: logseq-android-builds
@@ -544,7 +544,7 @@ jobs:
         id: ref
         run: |
           pkgver=$(cat VERSION)
-          echo ::set-output name=version::$pkgver
+          echo "version=$pkgver" >> $GITHUB_OUTPUT
 
       - name: Fix .nupkg name in RELEASES file
         run: |

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

@@ -7,7 +7,7 @@ on:
     types: [released]
 
 env:
-  CLOJURE_VERSION: '1.10.1.727'
+  CLOJURE_VERSION: '1.10.1.763'
 
 jobs:
 

+ 1 - 1
.github/workflows/build-ios-release.yml

@@ -12,7 +12,7 @@ on:
 
 env:
   CLOJURE_VERSION: '1.10.1.763'
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   JAVA_VERSION: '11'
 
 jobs:

+ 7 - 7
.github/workflows/build-ios.yml

@@ -17,7 +17,7 @@ on:
 
 env:
   CLOJURE_VERSION: '1.10.1.763'
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   JAVA_VERSION: '11'
 
 jobs:
@@ -28,16 +28,16 @@ jobs:
         uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c  # v3.3.0
 
       - name: Install Node.js, NPM and Yarn
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
 
       - name: Get yarn cache directory path
         id: yarn-cache-dir-path
-        run: echo "::set-output name=dir::$(yarn cache dir)"
+        run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache yarn cache directory
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: yarn-cache
         with:
           path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -46,13 +46,13 @@ jobs:
             ${{ runner.os }}-yarn-
 
       - name: Setup Java JDK
-        uses: actions/setup-java@v2
+        uses: actions/setup-java@v3
         with:
           distribution: 'zulu'
           java-version: ${{ env.JAVA_VERSION }}
 
       - name: Cache clojure deps
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: |
             ~/.m2/repository
@@ -60,7 +60,7 @@ jobs:
           key: ${{ runner.os }}-clojure-lib-${{ hashFiles('**/deps.edn') }}
 
       - name: Setup clojure
-        uses: DeLaGuardo/setup-clojure@3.5
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
 

+ 2 - 2
.github/workflows/build-stage.yml

@@ -23,9 +23,9 @@ jobs:
           java-version: 1.8
 
       - name: Set up Node
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
-            node-version: 16
+            node-version: 18
 
       - name: Install clojure
         run: |

+ 15 - 7
.github/workflows/build.yml

@@ -11,15 +11,23 @@ on:
       - '*.md'
 
 env:
-  CLOJURE_VERSION: '1.10.1.727'
-  # setup-java@v2 dropped support for legacy Java version syntax.
-  # This is the same as 1.8.
-  JAVA_VERSION: '8'
+  CLOJURE_VERSION: '1.10.1.763'
+  JAVA_VERSION: '11'
   # This is the latest node version we can run.
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   BABASHKA_VERSION: '1.0.168'
 
 jobs:
+  typos:
+    name: Spell Check with Typos
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout Actions Repository
+        uses: actions/checkout@v3
+      - name: Check spelling with custom config file
+        uses: crate-ci/[email protected]
+        with:
+          config: ./typos.toml
 
   test:
     strategy:
@@ -106,7 +114,7 @@ jobs:
         run: bb lint:ns-docstrings 2>/dev/null
 
       - name: Lint invalid translation entries
-        run: bb lang:invalid-translations
+        run: bb lang:validate-translations
 
   e2e-test:
     runs-on: ubuntu-latest
@@ -136,7 +144,7 @@ jobs:
           cli: ${{ env.CLOJURE_VERSION }}
 
       - name: Clojure cache
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: clojure-deps
         with:
           path: |

+ 5 - 6
.github/workflows/db.yml

@@ -20,11 +20,10 @@ defaults:
     working-directory: deps/db
 
 env:
-  CLOJURE_VERSION: '1.10.1.727'
-  # This is the same as 1.8.
-  JAVA_VERSION: '8'
+  CLOJURE_VERSION: '1.10.1.763'
+  JAVA_VERSION: '11'
   # This is the latest node version we can run.
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   BABASHKA_VERSION: '1.0.168'
 
 jobs:
@@ -49,7 +48,7 @@ jobs:
           java-version: ${{ env.JAVA_VERSION }}
 
       - name: Set up Clojure
-        uses: DeLaGuardo/setup-clojure@master
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
           bb: ${{ env.BABASHKA_VERSION }}
@@ -75,7 +74,7 @@ jobs:
           java-version: ${{ env.JAVA_VERSION }}
 
       - name: Set up Clojure
-        uses: DeLaGuardo/setup-clojure@master
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
           bb: ${{ env.BABASHKA_VERSION }}

+ 9 - 11
.github/workflows/e2e.yml

@@ -15,12 +15,10 @@ on:
       - 'e2e-tests/**'
 
 env:
-  CLOJURE_VERSION: '1.10.1.727'
-  # setup-java@v2 dropped support for legacy Java version syntax.
-  # This is the same as 1.8.
-  JAVA_VERSION: '8'
+  CLOJURE_VERSION: '1.10.1.763'
+  JAVA_VERSION: '11'
   # This is the latest node version we can run.
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   BABASHKA_VERSION: '1.0.168'
 
 jobs:
@@ -32,7 +30,7 @@ jobs:
         uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c  # v3.3.0
 
       - name: Set up Node
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
           cache: 'yarn'
@@ -41,18 +39,18 @@ jobs:
             static/yarn.lock
 
       - name: Set up Java
-        uses: actions/setup-java@v2
+        uses: actions/setup-java@v3
         with:
           distribution: 'zulu'
           java-version: ${{ env.JAVA_VERSION }}
 
       - name: Set up Clojure
-        uses: DeLaGuardo/setup-clojure@master
+        uses: DeLaGuardo/setup-clojure@10.1
         with:
           cli: ${{ env.CLOJURE_VERSION }}
 
       - name: Clojure cache
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         id: clojure-deps
         with:
           path: |
@@ -66,7 +64,7 @@ jobs:
         run: clojure -A:cljs -P
 
       - name: Shadow-cljs cache
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: .shadow-cljs
           # ensure update cache every time
@@ -121,7 +119,7 @@ jobs:
         run: tar xzf static.tar.gz
 
       - name: Set up Node
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
           cache: 'yarn'

+ 3 - 3
.github/workflows/graph-parser.yml

@@ -24,11 +24,11 @@ defaults:
     working-directory: deps/graph-parser
 
 env:
-  CLOJURE_VERSION: '1.10.1.727'
+  CLOJURE_VERSION: '1.10.1.763'
   # This is the same as 1.8.
-  JAVA_VERSION: '8'
+  JAVA_VERSION: '11'
   # This is the latest node version we can run.
-  NODE_VERSION: '16'
+  NODE_VERSION: '18'
   BABASHKA_VERSION: '1.0.168'
 
 jobs:

+ 1 - 1
.gitignore

@@ -33,9 +33,9 @@ strings.csv
 
 .calva
 resources/electron.js
+.lsp/.cache
 .clj-kondo/.cache
 .clj-kondo/babashka/sci
-.lsp/
 /libs/dist/
 charlie/
 .vscode

+ 2 - 0
.lsp/config.edn

@@ -0,0 +1,2 @@
+{:source-aliases #{:cljs}
+ :clean {:ns-inner-blocks-indentation :same-line}}

+ 174 - 0
CONTRIBUTING.md

@@ -0,0 +1,174 @@
+# Contributing to Logseq
+
+Thanks for your interest! :heart: :man_dancing: :woman_dancing: We would love
+for you to contribute to Logseq and help make it even better than it is today!
+
+As a contributor, here is an overview of things to learn about and ways to get involved:
+
+- [Code of Conduct](#coc)
+- [How can I help?](#how-can-i-help)
+- [Question or Problem?](#question)
+- [Issues and Bugs](#issue)
+- [Feature Requests](#feature)
+- [Submit an Issue](#submit-issue)
+- [Submit a Pull Request](#submit-pr)
+
+## <a name="coc"></a> Code of Conduct
+
+Help us keep Logseq open and inclusive.
+Please read and follow our [Code of Conduct][coc].
+
+## <a name="how-can-i-help"></a> How can I help?
+
+There are many ways you can help. Here are some ways to help without coding:
+
+- You can be help others on [Discord][discord] or [Reddit](https://www.reddit.com/r/logseq).
+- You can [contribute to the official docs](https://github.com/logseq/docs/blob/master/CONTRIBUTING.md).
+- You can confirm bugs on the [issue tracker][issue-tracker] and mention reproducible steps. It helps the core team to get more reports so we can fix the highest priority bugs.
+- You can contribute [translations][translations] with a [pull request](#submit-pr).
+
+For ways to help with coding, read the next section.
+
+### <a name="code-contributions"></a> Code Contributions
+
+For contributors who want to help with coding, we have a list of [good first
+issues](https://github.com/logseq/logseq/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
+to help you get started. These are issues that are beginner-friendly and do not
+require advanced knowledge of the codebase. We encourage new contributors to
+start with these issues and gradually work their way up to more challenging
+tasks. We also have a project board to keep track of community contributions
+[Logseq - Develop Together
+💪](https://github.com/orgs/logseq/projects/5?query=is%3Aopen+sort%3Aupdated-desc).
+Another way to help with coding is by extending Logseq with
+[plugins](https://docs.logseq.com/#/page/Plugins) and submit them to the [marketplace](https://github.com/logseq/marketplace) so that the
+whole community can benefit.
+
+## <a name="question"></a> Got a Question or a Problem?
+
+Please do not open issues for general support questions or feature requests as we want to keep GitHub issues for bug reports.
+Instead, we recommend using [Logseq forum][forum] to ask support-related questions.
+
+The Logseq forum is a much better place to ask questions since:
+
+- there are more people willing to help on the forum
+- questions and answers stay available for public viewing so your question/answer might help someone else
+- The forum's voting system assures that the best answers are prominently visible.
+
+To save your and our time, we will systematically close all issues that are requests for general support and redirect people to the forum.
+
+If you would like to chat about the question in real-time, you can reach out via [our Discord server][discord].
+
+## <a name="issue"></a> Found a Bug?
+
+If you find a bug, you can help us by [submitting an issue](#submit-issue) to our [GitHub Repository][github].
+Even better, you can [submit a Pull Request](#submit-pr) with a fix.
+
+## <a name="feature"></a> Missing a Feature?
+
+You can *request* a new feature by [Creating a thread][feature-request] in our forum.
+If you would like to *implement* a new feature, please open an issue and outline your proposal so that it can be discussed.
+
+## <a name="submit-issue"></a> Submit an Issue
+
+Before you submit an issue, please search the [issue tracker][issue-tracker]. An issue for your problem might already exist and the discussion might inform you of workarounds readily available.
+
+To submit an issue, [fill out the bug report template][new-issue]. Please file a
+single issue per problem and do not enumerate multiple bugs in the same issue.
+
+The template will ask you to include the following with each issue:
+
+- Version of Logseq
+- Your operating system
+- List of extensions that you have installed. Attempt to recreate the issue after disabling all extensions.
+- Reproducible steps (1... 2... 3...) that cause the issue
+- What you expected to see, versus what you actually saw
+- Images, animations, or a link to a video showing the issue occurring
+- A code snippet that demonstrates the issue or a link to a  code repository the developers can easily pull down to recreate the  issue locally
+  - **Note:** Because the developers need to copy and paste the code snippet, including a code snippet as a media file (i.e. .gif)  is not sufficient.
+- Errors from the Dev Tools Console (open from the menu: View > Toggle Developer Tools or press CTRL + Shift + i)
+
+## <a name="submit-pr"></a> Submit a Pull Request (PR)
+
+Before working on your pull request, please check the following:
+
+1. Search [GitHub][search-pr] for related PRs that may effect your submission.
+
+2. Be sure that an issue describes the problem you're fixing or the feature
+behavior and design you'd like to add.
+
+3. Please sign our [Contributor License Agreement (CLA)](#cla). We cannot accept
+code without a signed CLA.
+
+After doing the above, you are ready to work on your PR! To create a PR, fork
+this repository and then create a branch for the fix. Once you push your code to
+your fork, you'll be able to open a PR to the Logseq repository. For more info
+you can follow this [GitHub
+guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork).
+For more github PR guides, see [these
+guides](https://docs.github.com/en/pull-requests).
+
+### PR Guidelines
+
+When submitting a Pull Request (PR) or expecting a subsequent review, please follow these guidelines:
+
+1. The PR is ready for review. If you you have work you know how to do, then please keep your changes local until they are ready. If you need help with your PR, feel free to submit with questions.
+
+2. The PR checks which include tests and [lint checks](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md#linting) are passing.
+
+3. The PR has no merge conflicts.
+
+4. The PR has [test(s)](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md#testing) for features or enhancements. Tests for bug fixes are also appreciated as they help prevent regressions.
+
+5. The PR has a descriptive title that a user can understand. We use these titles to generate changelogs for the user. Most titles use one these prefixes to categorize the PR e.g. `PREFIX: DESCRIPTION ...`:
+   * `chore` - Misc changes that aren't dev, feat or fix
+   * `dev` - Developer related changes
+   * `enhance` - Enhancements i.e. changes to existing features
+   * `feat` or `feature` - New features
+   * `fix` - Bug fixes
+   * `test` - Test only changes
+
+6.  The PR having "allow edits from maintainers" enabled would be appreciated. Helps us help your contribution.
+
+7. The PR avoids the following changes that are not helpful to the core team:
+   * Unrelated refactoring or heavy refactoring
+   * Code or doc formatting changes including whitespace changes
+   * Dependency updates e.g. in package.json
+
+### PR Additional Links
+
+* To run Logseq locally, see [this doc](https://github.com/logseq/logseq/blob/master/docs/develop-logseq.md) or [this doc for windows](https://github.com/logseq/logseq/blob/master/docs/develop-logseq-on-windows.md).
+* To contribute to translations, please read our [translation contribution guidelines][translations].
+* See [our development practices doc](https://github.com/logseq/logseq/blob/master/docs/dev-practices.md) to learn how we develop.
+* See [the overview doc](CODEBASE_OVERVIEW.md) to get an overview of the codebase.
+
+### <a name="cla"></a> Sign the CLA
+
+Please sign our Contributor License Agreement (CLA) before sending pull requests. For any code
+changes to be accepted, the CLA must be signed. It's a quick process, we promise!
+
+- For individuals, we have a [simple click-through form][individual-cla].
+- For corporations, please contact us.
+
+If you have more than one GitHub accounts or multiple email addresses associated with a single GitHub account, you must sign the CLA using the primary email address of the GitHub account used to author Git commits and send pull requests.
+
+The following documents can help you sort out issues with GitHub accounts and multiple email addresses:
+
+- <https://help.github.com/articles/setting-your-commit-email-address-in-git/>
+- <https://stackoverflow.com/questions/37245303/what-does-usera-committed-with-userb-13-days-ago-on-github-mean>
+- <https://help.github.com/articles/about-commit-email-addresses/>
+- <https://help.github.com/articles/blocking-command-line-pushes-that-expose-your-personal-email-address/>
+
+## Thank You
+
+Your contributions to open source, large or small, make great projects like this possible. Thank you for taking the time to contribute.
+
+[coc]: https://github.com/logseq/logseq/blob/master/CODE_OF_CONDUCT.md "Logseq Code Of Conduct"
+[translations]: https://github.com/logseq/logseq/blob/master/docs/contributing-to-translations.md "contributing to translations"
+[github]: https://github.com/logseq/logseq "Logseq Repo"
+[discord]: https://discord.gg/KpN4eHY "Logseq Discord Server"
+[individual-cla]: https://cla-assistant.io/logseq/logseq "Individual CLA"
+[feature-request]: https://discuss.logseq.com/c/feature-requests/ "Submit Feature Request"
+[forum]: https://discuss.logseq.com "Logseq Forum"
+[search-pr]: https://github.com/logseq/logseq/pulls "Search open PRs"
+[new-issue]: https://github.com/logseq/logseq/issues/new?assignees=&labels=&template=bug_report.yaml "Submit a New issue"
+[issue-tracker]: https://github.com/logseq/logseq/issues "Logseq Issue Tracker"

+ 3 - 3
Dockerfile

@@ -1,6 +1,6 @@
 # NOTE: please keep it in sync with .github pipelines
 # NOTE: during testing make sure to change the branch below
-# NOTE: before runing the build-docker GH action edit
+# NOTE: before running the build-docker GH action edit
 #       build-docker.yml and change the release channel from :latest to :testing
 
 # Builder image
@@ -16,7 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     gpg
 
 # install NodeJS & yarn
-RUN curl -sL https://deb.nodesource.com/setup_16.x | bash -
+RUN curl -sL https://deb.nodesource.com/setup_18.x | bash -
 
 RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | \
     tee /etc/apt/trusted.gpg.d/yarn.gpg && \
@@ -34,7 +34,7 @@ RUN yarn config set network-timeout 240000 -g && yarn install
 RUN  yarn release 
 
 # Web App Runner image
-FROM nginx:stable-alpine
+FROM nginx:1.23.3-alpine
 
 COPY --from=builder /data/static /usr/share/nginx/html
 

+ 172 - 68
README.md

@@ -1,104 +1,208 @@
-# Logseq
+<!-- logo -->
+<p align="center">
+    <a href="https://logseq.com" alt="Logseq Logo">
+    <img src="https://user-images.githubusercontent.com/25513724/220608753-f33db466-af72-4611-b603-411440c15ed0.png?sanatize=true" height="173"/></a>
+</p>
+
+<h1 align="center"> Logseq </h1>
+
+<h4 align="center">
+    A privacy-first, open-source platform for knowledge management and collaboration
+</h4>
+
+<div align="center">
+    <a href="https://logseq.com">Home Page</a> |
+    <a href="https://blog.logseq.com/">Blog</a> |
+    <a href="https://docs.logseq.com/">Documentation</a> |
+    <a href="https://trello.com/b/8txSM12G/roadmap">Roadmap</a>
+</div>
+<br></br>
+
+<p align="center">
+    <a href="https://github.com/logseq/logseq/releases/latest/">
+        <img src="https://img.shields.io/badge/Download_Logseq-100000?style=for-the-badge&logo=flatpak&logoColor=white&labelColor=002b36&color=85c8c8"
+            alt="Download Logseq"/></a>
+</p>
+
+<!-- social badges -->
+<p align="center">
+    <a href="https://discuss.logseq.com">
+        <img src="https://img.shields.io/badge/forum-Logseq-blue.svg?&color=%2385c8c8&logo=discourse&style=for-the-badge"
+            alt="forum"></a>
+    <a href="https://discord.gg/KpN4eHY">
+        <img src="https://img.shields.io/discord/725182569297215569?color=%2385c8c8&label=Discord&logo=discord&style=for-the-badge"
+            alt="chat on Discord"></a>
+    <a href="https://twitter.com/intent/follow?screen_name=logseq">
+        <img src="https://img.shields.io/twitter/follow/logseq?color=%2385c8c8&label=%40logseq&logo=twitter&style=for-the-badge"
+            alt="follow on Twitter"></a>
+</p>
 
-[![latest release version](https://img.shields.io/github/v/release/logseq/logseq)](https://github.com/logseq/logseq/releases)
-[![License](https://img.shields.io/github/license/logseq/logseq?color=blue)](https://github.com/logseq/logseq/blob/master/LICENSE.md)
-[![Twitter follow](https://img.shields.io/badge/follow-%40logseq-blue.svg?style=flat&logo=twitter)](https://twitter.com/logseq)
-[![forum](https://img.shields.io/badge/forum-Logseq-blue.svg?style=flat&logo=discourse)](https://discuss.logseq.com)
-[![discord](https://img.shields.io/discord/725182569297215569?label=discord&logo=Discord&color=blue)](https://discord.gg/KpN4eHY)
-[![total](https://opencollective.com/logseq/tiers/badge.svg?color=blue)](https://opencollective.com/logseq)
+<!-- dev badges -->
+<p align="center">
+    <a href="https://github.com/logseq/logseq/graphs/contributors" alt="Contributors">
+        <img src="https://img.shields.io/github/contributors/logseq/logseq?color=%2385c8c8&style=for-the-badge"/></a>
+    <a href="#-support-logseq-development" alt="Backers on Open Collective">
+        <img src="https://img.shields.io/opencollective/backers/logseq?color=%2385c8c8&style=for-the-badge"/></a>
+    <a href="#-sponsors" alt="Sponsors on Open Collective">
+        <img src="https://img.shields.io/opencollective/sponsors/logseq?color=%2385c8c8&style=for-the-badge"/></a>
+    <a href="https://github.com/logseq/logseq/blob/master/LICENSE.md" alt="Activity">
+        <img src="https://img.shields.io/github/license/logseq/logseq?color=%2385c8c8&style=for-the-badge"/></a>
+    <a href="https://github.com/logseq/logseq/releases">
+        <img src="https://img.shields.io/github/v/release/logseq/logseq?color=%2385c8c8&style=for-the-badge"
+            alt="latest release version"></a>
+</p>
 
-[![Contributors](https://opencollective.com/logseq/tiers/sponsors.svg?avatarHeight=24&width=600)](https://opencollective.com/logseq)
-[![Contributors](https://opencollective.com/logseq/tiers/backers.svg?avatarHeight=24&width=600)](https://opencollective.com/logseq)
+## Table of Contents
 
-A local-first, non-linear, outliner notebook for organizing and sharing your personal knowledge base.
+  * [<g-emoji class="g-emoji" alias="thinking" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f914.png">🤔</g-emoji> Why Logseq?](#-why-logseq)
+  * [<g-emoji class="g-emoji" alias="eyes" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f440.png">👀</g-emoji> How can I use it?](#-how-can-i-use-it)
+  * [<g-emoji class="g-emoji" alias="books" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4da.png">📚</g-emoji> Learn more](#-learn-more)
+  * [🫶 Support Logseq Development](#-support-logseq-development)
+  * [<g-emoji class="g-emoji" alias="bulb" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f4a1.png">💡</g-emoji> Feature requests](#-feature-requests)
+  * [<g-emoji class="g-emoji" alias="electric_plug" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f50c.png">🔌</g-emoji> Plugin API](#-plugin-api)
+  * [<g-emoji class="g-emoji" alias="star2" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f31f.png">🌟</g-emoji> Contributing to Logseq](#-contributing-to-logseq)
+    * [<g-emoji class="g-emoji" alias="hammer_and_wrench" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f6e0.png">🛠️</g-emoji> Setting Up a Development Environment](#️-setting-up-a-development-environment)
+  * [<g-emoji class="g-emoji" alias="sparkles" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/2728.png">✨</g-emoji> Inspiration](#-inspiration)
+* [<g-emoji class="g-emoji" alias="pray" fallback-src="https://github.githubassets.com/images/icons/emoji/unicode/1f64f.png">🙏</g-emoji> Thank You](#-thank-you)
 
-Use it to organize your todo list, to write your journals, or to record your unique life.
+## 🤔 Why Logseq?
 
-<a href="https://www.producthunt.com/posts/logseq?utm_source=badge-review&utm_medium=badge&utm_souce=badge-logseq#discussion-body" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/review.svg?post_id=298158&theme=light" alt="Logseq - Your joyful, private digital garden | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
+[Logseq](https://logseq.com) is a **knowledge management** and **collaboration** platform. It focuses on **privacy**, **longevity**, and [**user control**](https://www.gnu.org/philosophy/free-sw.en.html). Logseq offers a range of **powerful tools** for **knowledge management**, **collaboration**, **PDF annotation**, and **task management** with support for multiple file formats, including **Markdown** and **Org-mode**, and **various features** for organizing and structuring your notes.
 
-## [Download our free Desktop app](https://github.com/logseq/logseq/releases)
-[Sponsor our contributors on Open Collective](https://opencollective.com/logseq), Logseq will move to Stripe later!
+Logseq's **Whiteboard** feature lets you organize your knowledge and ideas using a spatial **canvas** with **shapes**, **drawings**, **website embeds**, and **connectors**. You can **visually group** and **link** your **notes** and external media (such as **videos** and **images**), enabling visual thinkers to compose, remix, **annotate**, and connect content from their knowledge base and emerging thoughts in a new way.
 
-## Why Logseq?
+In addition to its core features, Logseq has a growing ecosystem of **plugins** and **themes** that enable a wide range of workflows and **customization** options. **Mobile apps** are also available, providing access to most of the features of the desktop application. Whether you're a student, a professional, or anyone who values a clear and organized approach to managing your ideas and notes, Logseq is an excellent choice for anyone looking to improve their productivity and streamline their workflow.
 
-[Logseq](https://logseq.com) is a platform for knowledge management and collaboration. It focuses on privacy, longevity, and [user control](https://www.gnu.org/philosophy/free-sw.en.html).
+![logseq-demo](https://user-images.githubusercontent.com/25513724/221387376-4dc419c2-0d0a-460c-a920-2d211e78b456.gif)
 
-The server will never store or analyze your private notes. Your data are plain text files and we currently support both Markdown and Emacs Org mode (more to be added soon).
+<a href="https://github.com/logseq/logseq/releases/latest/">
+        <img src="https://img.shields.io/badge/Download_Logseq-100000?style=for-the-badge&logo=flatpak&logoColor=white&labelColor=002b36&color=85c8c8"
+            align="right"
+            alt="Download Logseq"/></a>
 
-In the unlikely event that the website is down or cannot be maintained, your data is, and will always be yours.
+## 👀 How can I use it?
 
-![Image of logseq](https://cdn.logseq.com/%2F8b9a461d-437e-4ca5-a2da-18b51077b5142020_07_25_Screenshot%202020-07-25%2013-29-49%20%2B0800.png?Expires=4749255017&Signature=Qbx6jkgAytqm6nLxVXQQW1igfcf~umV1OcG6jXUt09TOVhgXyA2Z5jHJ3AGJASNcphs31pZf4CjFQ5mRCyVKw6N8wb8Nn-MxuTJl0iI8o-jLIAIs9q1v-2cusCvuFfXH7bq6ir8Lpf0KYAprzuZ00FENin3dn6RBW35ENQwUioEr5Ghl7YOCr8bKew3jPV~OyL67MttT3wJig1j3IC8lxDDT8Ov5IMG2GWcHERSy00F3mp3tJtzGE17-OUILdeuTFz6d-NDFAmzB8BebiurYz0Bxa4tkcdLUpD5ToFHU08jKzZExoEUY8tvaZ1-t7djmo3d~BAXDtlEhC2L1YC2aVQ__&Key-Pair-Id=APKAJE5CCD6X7MP6PTEA)
+To start using Logseq, follow these simple steps:
 
-## Sponsors
+1. [Download](https://github.com/logseq/logseq/releases/latest) the latest version of Logseq
+2. Install Logseq on your device and launch the application
+3. Start writing ✍️
 
-Our top sponsors are shown below! [[Become a sponsor](https://opencollective.com/logseq#sponsor)]
+That's it! You can now enjoy the benefits of using Logseq to streamline your workflow, manage your projects, and stay on top of your goals. Have fun! 🎉
 
-<a href="https://www.deta.sh/" target="_blank"><img width=200 height=100 src="https://uploads-ssl.webflow.com/5eb96efa78dc680fc15be3be/5ebd24f6cbf6e9ebd674656e_Logo.svg" /></a>
+## 📚 Learn more
 
+* Website: [logseq.com](https://logseq.com)
+* Documentation: [docs.logseq.com](https://docs.logseq.com)
+  * FAQ page: [Logseq Docs:  FAQ](https://docs.logseq.com/#/page/faq)
+* Blog: [blog.logseq.com](https://blog.logseq.com)
+  * Please visit our [About page](https://blog.logseq.com/about) for the latest updates.
+* Logseq Hub: [hub.logseq.com](https://hub.logseq.com)
+* Forum: [discuss.logseq.com](https://discuss.logseq.com) - Where we answer questions, discuss workflows, and share tips
+  * FAQ forum section: [Logseq Forum: FAQ](https://discuss.logseq.com/c/faq/6)
+* [Awesome Logseq](https://github.com/logseq/awesome-logseq) - Awesome Logseq extensions and resources created by the community <3
+* Twitter: [@Logseq](https://twitter.com/logseq)
+* Discord: [discord.gg/logseq](https://discord.gg/logseq)
+  * [中文 Discord](https://discord.gg/xYqcrXWymg)
 
-## Plugins documentation (Draft)
-The plugins documentation is at https://logseq.github.io/plugins. Any feedback would be greatly appreciated!
+## 🫶 Support Logseq Development
 
-## Feature requests
+If you find Logseq useful and want to help us keep the project growing, please consider supporting our contributors on [Open Collective](https://opencollective.com/logseq). Your support shows our contributors that their efforts are appreciated and motivates them to continue their excellent work. Every contribution, no matter how small, helps us keep improving Logseq.
 
-Please go to https://discuss.logseq.com/new-topic?category=feature-requests.
+## 💡 Feature requests
 
-## How can I use it?
+We value your input on improving Logseq and making it more useful for you. If you have any ideas or feature requests, please share them in the [Logseq Forum: Feature
+Requests](https://discuss.logseq.com/new-topic?category=feature-requests) section.
 
-1. Download the desktop app at https://github.com/logseq/logseq/releases.
-2. Start writing and have fun!
+Your feedback helps us understand our users' needs and prioritize the features that matter most to you. We appreciate your time and effort in sharing your thoughts with us.
 
-## FAQ
-Please go to https://docs.logseq.com/#/page/faq.
+We appreciate your support, and we look forward to hearing your ideas!
 
-## Credits
+## 🔌 Plugin API
 
-Logseq is hugely inspired by [Roam Research](https://roamresearch.com/), [Org Mode](https://orgmode.org/), [Tiddlywiki](https://tiddlywiki.com/), [Workflowy](https://workflowy.com/) and [Cuekeeper](https://github.com/talex5/cuekeeper), hats off to all of them!
+Logseq provides a plugin API that enables developers to create custom plugins and extend the functionality of Logseq. The plugin API documentation is available at [plugins-doc.logseq.com](https://plugins-doc.logseq.com/), where you can find everything needed to get started with plugin development.
 
-Logseq is also made possible by the following projects:
-
-- [Clojure & ClojureScript](https://clojure.org/) - A dynamic, functional, general-purpose programming language
-- [DataScript](https://github.com/tonsky/datascript) - Immutable database and Datalog query-engine for Clojure, ClojureScript and JS
-- [OCaml](https://ocaml.org/) & [Angstrom](https://github.com/inhabitedtype/angstrom), for the document [parser](https://github.com/mldoc/mldoc)
-- [isomorphic-git](https://isomorphic-git.org/) - A pure JavaScript implementation of Git for node and browsers
-- [sci](https://github.com/borkdude/sci) - Small Clojure Interpreter
-
-![Logseq Credits](https://asset.logseq.com/static/img/credits.png)
-
-## Learn more
+We value your feedback and suggestions on how to improve our documentation. Please do not hesitate to contact us with any comments or questions. Your input helps us to provide a better experience for our users and developers.
 
-- Our blog: [https://blog.logseq.com/](https://blog.logseq.com) - Please be sure to visit our [About page](https://blog.logseq.com/about) for the latest updates of the app
-- Twitter: https://twitter.com/logseq
-- Forum: https://discuss.logseq.com - Where we answer questions, discuss workflows and share tips
-- Discord: https://discord.gg/KpN4eHY
-- 中文 Discord:https://discord.gg/xYqcrXWymg
-- GitHub: https://github.com/logseq/logseq - everyone is encouraged to report issues!
+Thank you for using Logseq, and we look forward to seeing what you create with our plugin API!
 
----
+## 🌟 Contributing to Logseq
 
-The following is for developers and designers who want to build and run Logseq locally and contribute to this project.
+To start contributing to Logseq, please read [CONTRIBUTING.md](CONTRIBUTING.md).
+There are ways to contribute [with code](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md#code-contributions) and [without code](https://github.com/logseq/logseq/blob/master/CONTRIBUTING.md#-how-can-i-help). We welcome all
+contributions, big or small, and we appreciate your time and effort in helping
+us improve Logseq. We look forward to your contributions 🚀
 
-We have [a dedicated overview page](https://github.com/logseq/logseq/blob/master/CODEBASE_OVERVIEW.md) for Logseq's codebase overview and [a development practices page](docs/dev-practices.md).
+### 🛠️ Setting Up a Development Environment
 
-## Set up development environment
-* For setting up web app / desktop app development environment on macOS / Linux, please refer to [Develop Logseq](docs/develop-logseq.md).
+If you want to set up a development environment for the Logseq web or desktop app, please refer to the [Develop Logseq](docs/develop-logseq.md) guide for macOS/Linux users and the [Develop Logseq on Windows](docs/develop-logseq-on-windows.md) guide for Windows users.
 
-* For Windows users, please refer to [Develop Logseq on Windows](docs/develop-logseq-on-windows.md) in addition.
+In addition to these guides, you can also find other helpful resources in the [docs/](docs/) folder, such as the [Guide for Contributing to Translations](docs/contributing-to-translations.md), the [Docker Web App Guide](docs/docker-web-app-guide.md) and the [mobile development guide](docs/develop-logseq-on-mobile.md)
 
-There are more guides in [docs/](docs/), e.g. the [Guide for contributing to translations](docs/contributing-to-translations.md) and the [Docker web app guide](docs/docker-web-app-guide.md)
+## ✨ Inspiration
 
-## How to contribute with a PR
-If you would like to contribute by solving an open issue, please fork this repository and then create a branch for the fix.
+Logseq is inspired by several unique tools and projects, including [Roam Research](https://roamresearch.com/), [Org Mode](https://orgmode.org/), [TiddlyWiki](https://tiddlywiki.com/), [Workflowy](https://workflowy.com/), and [Cuekeeper](https://github.com/talex5/cuekeeper).
 
-Once you push your code to your fork, you'll be able to open a PR into Logseq repository. For more info you can follow this guide from [GitHub docs](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). 
+We owe a huge debt of gratitude to the developers and creators of these projects, and we hope that Logseq can continue to build on their innovative ideas and make them accessible to a broader audience.
 
-Enabling "allow edits from maintainers" for PR is highly appreciated!
+Thank you to all those who inspire us, and we look forward to seeing what the Logseq community will create with this tool!
 
-There's a nice [project board](https://github.com/orgs/logseq/projects/5/views/1?pane=info
-) listing items that easy for contributors to catch-up
-
-And here a list of some [good first issues](https://github.com/logseq/logseq/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)!
-
-## Thanks
+Logseq is also made possible by the following projects:
 
-[![JetBrains](docs/assets/jetbrains.svg)](https://www.jetbrains.com/?from=logseq)
+* [Clojure & ClojureScript](https://clojure.org/) - A dynamic, functional, general-purpose programming language
+* [DataScript](https://github.com/tonsky/datascript) - An immutable database and Datalog query-engine for Clojure,
+ClojureScript and JS
+* [OCaml](https://ocaml.org/) & [Angstrom](https://github.com/inhabitedtype/angstrom), for the document parser [maldoc](https://github.com/mldoc/mldoc)
+* [isomorphic-git](https://isomorphic-git.org/) - A pure JavaScript implementation of Git for NodeJS and web browsers
+* [SCI](https://github.com/borkdude/sci) - A Small Clojure Interpreter
+
+# 🙏 Thank You
+
+We want to express our sincere gratitude to our [Open Collective](https://opencollective.com/logseq) **sponsors**, **backers**, and **contributors**. Your support and contributions allow us to continue developing and improving Logseq. Thank you for being a part of our community and helping us make Logseq the best it can be!
+
+## 💎 Sponsors
+
+<p align="center">
+    <a href="https://opencollective.com/logseq#sponsor"> [Become a sponsor]</a>
+</p>
+<!-- Deta Logo -->
+<p align="center">
+    <a href="https://www.deta.sh/" alt="Deta">
+        <img src="https://uploads-ssl.webflow.com/5eb96efa78dc680fc15be3be/5ebd24f6cbf6e9ebd674656e_Logo.svg"
+        style="width: 200px; height: 100px;"/></a>
+</p>
+
+<p align="center">
+    <a href="https://opencollective.com/logseq" alt="Sponsors on Open Collective">
+        <img src="https://opencollective.com/logseq/tiers/sponsors.svg?avatarHeight=42&width=600"/></a>
+</p>
+
+## 🌟 Contributors
+
+<p align="center">
+    <a href="https://github.com/logseq/logseq/graphs/contributors">
+        <img src="https://contrib.rocks/image?repo=logseq/logseq&max=300&columns=14" width="600"/></a>
+</p>
+
+## 🫶 Backers
+
+<p align="center">
+    <a href="https://opencollective.com/logseq" alt="Backers on Open Collective">
+        <img src="https://opencollective.com/logseq/tiers/backers.svg?avatarHeight=24&width=600"/></a>
+</p>
+
+<!-- JetBrains Logo -->
+<p align="center">
+    <a href="https://jetbrains.com" alt="JetBrains">
+        <img src="docs/assets/jetbrains.svg"/></a>
+</p>
+
+<!-- ProductHunt Review Button -->
+<p align="center">
+    <a href="https://www.producthunt.com/posts/logseq?utm_source=badge-review&utm_medium=badge&utm_souce=badge-logseq#discussion-body"
+    target="_blank"><img
+        src="https://api.producthunt.com/widgets/embed-image/v1/review.svg?post_id=298158&theme=dark"
+        align="center"
+        alt="Logseq - Your joyful, private digital garden | Product Hunt" style="width: 250px; height: 54px;"
+        width="250" height="54"/></a>
+</p>

+ 2 - 2
android/app/build.gradle

@@ -6,8 +6,8 @@ android {
         applicationId "com.logseq.app"
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
-        versionCode 50
-        versionName "0.8.16"
+        versionCode 52
+        versionName "0.8.18"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         aaptOptions {
              // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

+ 5 - 2
bb.edn

@@ -69,6 +69,9 @@
   dev:validate-global-config-edn
   logseq.tasks.malli/validate-global-config-edn
 
+  dev:validate-ast
+  logseq.tasks.malli/validate-ast
+
   dev:lint
   logseq.tasks.dev/lint
 
@@ -99,8 +102,8 @@
   lang:duplicates
   logseq.tasks.lang/list-duplicates
 
-  lang:invalid-translations
-  logseq.tasks.lang/invalid-translations
+  lang:validate-translations
+  logseq.tasks.lang/validate-translations
 
   file-sync:integration-tests
   logseq.tasks.file-sync/integration-tests}

+ 2 - 1
deps.edn

@@ -29,7 +29,8 @@
   org.clojars.mmb90/cljs-cache          {:mvn/version "0.1.4"}
   logseq/graph-parser                   {:local/root "deps/graph-parser"}
   logseq/publish                        {:local/root "deps/publish"}
-  metosin/malli                         {:mvn/version "0.10.0"}}
+  metosin/malli                         {:mvn/version "0.10.0"}
+  fipp/fipp                             {:mvn/version "0.6.26"}}
 
  :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
                   :extra-deps  {org.clojure/clojurescript        {:mvn/version "1.11.54"}

+ 1 - 1
deps/db/README.md

@@ -46,7 +46,7 @@ bb lint:rules
 The package.json dependencies are just for testing and should be updated if there is
 new behavior to test.
 
-The deps.edn dependecies are used by both ClojureScript and nbb-logseq. Their
+The deps.edn dependencies are used by both ClojureScript and nbb-logseq. Their
 versions should be backwards compatible with each other with priority given to
 the frontend. _No new dependency_ should be introduced to this library without
 an understanding of the tradeoffs of adding this to nbb-logseq.

+ 12 - 2
deps/db/src/logseq/db/default.cljs

@@ -1,9 +1,19 @@
 (ns logseq.db.default
   "Provides fns for seeding default data in a logseq db"
-  (:require [clojure.string :as string]))
+  (:require [clojure.string :as string]
+            [clojure.set :as set]))
+
+(defonce built-in-markers
+  ["NOW" "LATER" "DOING" "DONE" "CANCELED" "CANCELLED" "IN-PROGRESS" "TODO" "WAIT" "WAITING"])
+
+(defonce built-in-priorities
+  ["A" "B" "C"])
 
 (defonce built-in-pages-names
-  #{"NOW" "LATER" "DOING" "DONE" "CANCELED" "CANCELLED" "IN-PROGRESS" "TODO" "WAIT" "WAITING" "A" "B" "C" "Favorites" "Contents" "card"})
+  (set/union
+   (set built-in-markers)
+   (set built-in-priorities)
+   #{"Favorites" "Contents" "card"}))
 
 (def built-in-pages
   (mapv (fn [p]

+ 2 - 2
deps/db/src/logseq/db/rules.cljc

@@ -31,7 +31,7 @@
 ;; https://docs.datomic.com/on-prem/query/query-executing.html#clause-order
 ;; Recursive optimization Reference:
 ;; https://stackoverflow.com/questions/42457136/recursive-datalog-queries-for-datomic-really-slow
-;; Should optimize for query the decendents of a block
+;; Should optimize for query the descendents of a block
 ;; Quote:
 ;; My theory is that your rules are not written in a way that Datalog can optimize for this read pattern - probably resulting in a traversal of all the entities. I suggest to rewrite them as follows:
 ;; [[(ubersymbol ?c ?p)
@@ -62,7 +62,7 @@
 
 (def ^:large-vars/data-var query-dsl-rules
   "Rules used by frontend.db.query-dsl. The symbols ?b and ?p respectively refer
-  to block and page. Do not alter them as they are programatically built by the
+  to block and page. Do not alter them as they are programmatically built by the
   query-dsl ns"
   {:page-property
    '[(page-property ?p ?key ?val)

+ 1 - 0
deps/db/src/logseq/db/schema.cljs

@@ -118,6 +118,7 @@
     :block/properties
     :block/properties-order
     :block/properties-text-values
+    :block/macros
     :block/invalid-properties
     :block/created-at
     :block/updated-at

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

@@ -42,3 +42,7 @@ logseq.graph-parser.util.db/resolve-input
 logseq.graph-parser.util/remove-nils
 ;; API
 logseq.graph-parser.text/get-file-basename
+;; API
+logseq.graph-parser.mldoc/mldoc-link?
+;; public var
+logseq.graph-parser.schema.mldoc/block-ast-coll-schema

+ 1 - 1
deps/graph-parser/README.md

@@ -57,7 +57,7 @@ yarn test
 The package.json dependencies are just for testing and should be updated if there is
 new behavior to test.
 
-The deps.edn dependecies are used by both ClojureScript and nbb-logseq. Their
+The deps.edn dependencies are used by both ClojureScript and nbb-logseq. Their
 versions should be backwards compatible with each other with priority given to
 the frontend. _No new dependency_ should be introduced to this library without
 an understanding of the tradeoffs of adding this to nbb-logseq.

+ 79 - 34
deps/graph-parser/src/logseq/graph_parser/block.cljs

@@ -181,6 +181,20 @@
          (remove string/blank?)
          distinct)))
 
+(defn- extract-block-refs
+  [nodes]
+  (let [ref-blocks (atom nil)]
+    (walk/postwalk
+     (fn [form]
+       (when-let [block (get-block-reference form)]
+         (swap! ref-blocks conj block))
+       form)
+     nodes)
+    (keep (fn [block]
+            (when-let [id (parse-uuid block)]
+              [:block/uuid id]))
+          @ref-blocks)))
+
 (defn extract-properties
   [properties user-config]
   (when (seq properties)
@@ -202,9 +216,10 @@
                                            v' (text/parse-property k v mldoc-ast user-config)]
                                        [k' v' mldoc-ast v])
                                      (do (swap! *invalid-properties conj k)
-                                         nil)))))
+                                       nil)))))
                           (remove #(nil? (second %))))
           page-refs (get-page-ref-names-from-properties properties user-config)
+          block-refs (extract-block-refs properties)
           properties-text-values (->> (map (fn [[k _v _refs original-text]] [k original-text]) properties)
                                       (into {}))
           properties (map (fn [[k v _]] [k v]) properties)
@@ -213,7 +228,8 @@
        :properties-order (map first properties)
        :properties-text-values properties-text-values
        :invalid-properties @*invalid-properties
-       :page-refs page-refs})))
+       :page-refs page-refs
+       :block-refs block-refs})))
 
 (defn- paragraph-timestamp-block?
   [block]
@@ -248,7 +264,7 @@
                               (assoc :repeated? true))))))]
     (apply merge m)))
 
-(defn convert-page-if-journal
+(defn- convert-page-if-journal-impl
   "Convert journal file name to user' custom date format"
   [original-page-name date-formatter]
   (when original-page-name
@@ -259,6 +275,8 @@
          [original-page-name (gp-util/page-name-sanity-lc original-page-name) day])
        [original-page-name page-name day]))))
 
+(def convert-page-if-journal (memoize convert-page-if-journal-impl))
+
 ;; TODO: refactor
 (defn page-name->map
   "Create a page's map structure given a original page name (string).
@@ -349,19 +367,9 @@
 
 (defn- with-block-refs
   [{:keys [title body] :as block}]
-  (let [ref-blocks (atom nil)]
-    (walk/postwalk
-     (fn [form]
-       (when-let [block (get-block-reference form)]
-         (swap! ref-blocks conj block))
-       form)
-     (concat title body))
-    (let [ref-blocks (keep (fn [block]
-                             (when-let [id (parse-uuid block)]
-                               [:block/uuid id]))
-                           @ref-blocks)
-          refs (distinct (concat (:refs block) ref-blocks))]
-      (assoc block :refs refs))))
+  (let [ref-blocks (extract-block-refs (concat title body))
+        refs (distinct (concat (:refs block) ref-blocks))]
+    (assoc block :refs refs)))
 
 (defn- block-keywordize
   [block]
@@ -518,20 +526,24 @@
                                                       (when (coll? refs)
                                                         refs))))
                                             (map :block/original-name))
-                         block {:uuid id
-                                :content content
-                                :level 1
-                                :properties properties
-                                :properties-order (vec properties-order)
-                                :properties-text-values properties-text-values
-                                :invalid-properties invalid-properties
-                                :refs property-refs
-                                :pre-block? true
-                                :unordered true
-                                :macros (extract-macros-from-ast body)
-                                :body body}
-                         block (with-page-block-refs block false supported-formats db date-formatter)]
-                     (block-keywordize block))
+                         block {:block/uuid id
+                                :block/content content
+                                :block/level 1
+                                :block/properties properties
+                                :block/properties-order (vec properties-order)
+                                :block/properties-text-values properties-text-values
+                                :block/invalid-properties invalid-properties
+                                :block/pre-block? true
+                                :block/unordered true
+                                :block/macros (extract-macros-from-ast body)
+                                :block/body body}
+                         {:keys [tags refs]}
+                         (with-page-block-refs {:body body :refs property-refs} false supported-formats db date-formatter)]
+                     (cond-> block
+                             tags
+                             (assoc :block/tags tags)
+                             true
+                             (assoc :block/refs (concat refs (:block-refs pre-block-properties)))))
                    (select-keys first-block [:block/format :block/page]))
                   blocks)
                  blocks)]
@@ -572,6 +584,7 @@
         block (if (get-in block [:properties :collapsed])
                 (-> (assoc block :collapsed? true)
                     (update :properties (fn [m] (dissoc m :collapsed)))
+                    (update :properties-text-values dissoc :collapsed)
                     (update :properties-order (fn [keys] (vec (remove #{:collapsed} keys)))))
                 block)
         block (assoc block
@@ -581,6 +594,7 @@
                 block)
         block (assoc block :body body)
         block (with-page-block-refs block with-id? supported-formats db date-formatter)
+        block (update block :refs concat (:block-refs properties))
         {:keys [created-at updated-at]} (:properties properties)
         block (cond-> block
                 (and created-at (integer? created-at))
@@ -590,6 +604,29 @@
                 (assoc :block/updated-at updated-at))]
     (dissoc block :title :body :anchor)))
 
+(defn fix-duplicate-id
+  [block]
+  (println "Logseq will assign a new id for this block: " block)
+  (-> block
+      (assoc :uuid (d/squuid))
+      (update :properties dissoc :id)
+      (update :properties-text-values dissoc :id)
+      (update :properties-order #(vec (remove #{:id} %)))
+      (update :content (fn [c]
+                         (let [replace-str (re-pattern
+                                            (str
+                                             "\n*\\s*"
+                                             (if (= :markdown (:format block))
+                                               (str "id" gp-property/colons " " (:uuid block))
+                                               (str (gp-property/colons-org "id") " " (:uuid block)))))]
+                           (string/replace-first c replace-str ""))))))
+
+(defn block-exists-in-another-page?
+  [db block-uuid current-page-name]
+  (when (and db current-page-name)
+    (when-let [block-page-name (:block/name (:block/page (d/entity db [:block/uuid block-uuid])))]
+      (not= current-page-name block-page-name))))
+
 (defn extract-blocks
   "Extract headings from mldoc ast.
   Args:
@@ -598,10 +635,12 @@
     `with-id?`: If `with-id?` equals to true, all the referenced pages will have new db ids.
     `format`: content's format, it could be either :markdown or :org-mode.
     `options`: Options supported are :user-config, :block-pattern :supported-formats,
-               :extract-macros, :date-formatter and :db"
-  [blocks content with-id? format {:keys [user-config] :as options}]
+               :extract-macros, :extracted-block-ids, :date-formatter, :page-name and :db"
+  [blocks content with-id? format {:keys [user-config db page-name extracted-block-ids] :as options}]
   {:pre [(seq blocks) (string? content) (boolean? with-id?) (contains? #{:markdown :org} format)]}
   (let [encoded-content (utf8/encode content)
+        *block-ids (or extracted-block-ids (atom #{}))
+        ;; TODO: nbb doesn't support `Atom`
         [blocks body pre-block-properties]
         (loop [headings []
                blocks (reverse blocks)
@@ -627,8 +666,14 @@
 
                 (heading-block? block)
                 (let [block' (construct-block block properties timestamps body encoded-content format pos-meta with-id? options)
-                      block'' (assoc block' :macros (extract-macros-from-ast (cons block body)))]
-                  (recur (conj headings block'') (rest blocks) {} {} []))
+                      block'' (assoc block' :macros (extract-macros-from-ast (cons block body)))
+                      block-uuid (:uuid block'')
+                      fixed-block (if (or (@*block-ids block-uuid)
+                                          (block-exists-in-another-page? db block-uuid page-name))
+                                    (fix-duplicate-id block'')
+                                    block'')]
+                  (swap! *block-ids conj (:uuid fixed-block))
+                  (recur (conj headings fixed-block) (rest blocks) {} {} []))
 
                 :else
                 (recur headings (rest blocks) timestamps properties (conj body block))))

+ 1 - 3
deps/graph-parser/src/logseq/graph_parser/extract.cljc

@@ -133,9 +133,7 @@
   (try
     (let [page (get-page-name file ast uri-encoded? filename-format)
           [page page-name _journal-day] (gp-block/convert-page-if-journal page date-formatter)
-          options' (-> options
-                       (assoc :page-name page-name
-                              :original-page-name page))
+          options' (assoc options :page-name page-name)
           blocks (->> (gp-block/extract-blocks ast content false format options')
                       (gp-block/with-parent-and-left {:block/name page-name})
                       (vec))

+ 13 - 1
deps/graph-parser/src/logseq/graph_parser/mldoc.cljc

@@ -13,7 +13,8 @@
             [logseq.graph-parser.utf8 :as utf8]
             [clojure.string :as string]
             [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.config :as gp-config]))
+            [logseq.graph-parser.config :as gp-config]
+            [logseq.graph-parser.schema.mldoc :as mldoc-schema]))
 
 (defonce parseJson (gobj/get Mldoc "parseJson"))
 (defonce parseInlineJson (gobj/get Mldoc "parseInlineJson"))
@@ -117,6 +118,7 @@
         original-ast))))
 
 (defn ->edn
+  {:malli/schema [:=> [:cat :string :string] mldoc-schema/block-ast-with-pos-coll-schema]}
   [content config]
   (if (string? content)
     (try
@@ -164,3 +166,13 @@
   (when (string? link)
     (some-> (first (inline->edn link (default-config format)))
             ast-link?)))
+
+(defn mldoc-link?
+  "Check whether s is a link (including page/block refs)."
+  [format s]
+  (let [result (inline->edn s (default-config format))]
+    (and
+     (= 1 (count result))
+     (let [result' (first result)]
+       (or (contains? #{"Nested_link"} (first result'))
+           (contains? #{"Page_ref" "Block_ref" "Complex"} (first (:url (second result')))))))))

+ 220 - 0
deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc

@@ -0,0 +1,220 @@
+(ns logseq.graph-parser.schema.mldoc
+  "Malli schema for mldoc AST")
+
+(defn- field-optional-and-maybe-nil
+  [k v]
+  [k {:optional true} [:maybe v]])
+
+(def pos-schema
+  [:map
+   [:start_pos :int]
+   [:end_pos :int]])
+
+(def nested-link-schema
+  [:schema {:registry {::nested-link
+                       [:map
+                        [:content :string]
+                        [:children [:sequential [:or
+                                                 [:tuple [:= "Label"] :string]
+                                                 [:tuple [:= "Nested_link"] [:ref ::nested-link]]]]]]}}
+   ::nested-link])
+
+(def timestamp-schema
+  [:map
+   [:date [:map
+           [:year :int]
+           [:month :int]
+           [:day :int]]]
+   [:wday :string]
+   (field-optional-and-maybe-nil
+    :time [:map
+           [:hour :int]
+           [:min :int]])
+   (field-optional-and-maybe-nil
+    :repetition
+    :any)
+   [:active :boolean]])
+
+(def ^:private time-range-schema
+  [:map
+   [:start [:ref ::timestamp]]
+   [:stop [:ref ::timestamp]]])
+
+(def ^:private link-schema
+  [:map
+   [:url [:or
+          [:cat [:= "File"] :string]
+          [:cat [:= "Search"] :string]
+          [:cat [:= "Complex"] [:map
+                                [:protocol :string]
+                                [:link :string]]]
+          [:cat [:= "Page_ref"] :string]
+          [:cat [:= "Block_ref"] :string]
+          [:cat [:= "Embed_data"] :string]]]
+   [:label [:sequential [:ref ::inline]]]
+   (field-optional-and-maybe-nil :title :string)
+   [:full_text :string]
+   [:metadata :string]])
+
+(def latex-fragment-schema
+  [:or
+   [:tuple [:= "Inline"] :string]
+   [:tuple [:= "Displayed"] :string]])
+
+(def inline-ast-schema
+  [:schema {:registry {::timestamp timestamp-schema
+                       ::time-range time-range-schema
+                       ::link link-schema
+                       ::inline
+                       [:or
+                        [:tuple [:= "Emphasis"]
+                         [:tuple
+                          [:tuple [:enum "Italic" "Bold" "Underline" "Strike_through" "Highlight"]]
+                          [:sequential [:ref ::inline]]]]
+
+                        [:tuple [:= "Break_Line"]]
+                        [:tuple [:= "Hard_Break_Line"]]
+                        [:tuple [:= "Verbatim"] :string]
+                        [:tuple [:= "Code"] :string]
+                        [:tuple [:= "Tag"] [:sequential [:ref ::inline]]]
+                        [:tuple [:= "Spaces"] :string]
+                        [:tuple [:= "Plain"] :string]
+                        [:tuple [:= "Link"] [:ref ::link]]
+                        [:tuple [:= "Nested_link"] nested-link-schema]
+                        [:tuple [:= "Target"] :string]
+                        [:tuple [:= "Subscript"] [:sequential [:ref ::inline]]]
+                        [:tuple [:= "Superscript"] [:sequential [:ref ::inline]]]
+                        [:tuple [:= "Footnote_Reference"] [:map
+                                                           [:id :int]
+                                                           [:name :string]
+                                                           (field-optional-and-maybe-nil
+                                                            :definition  [:sequential [:ref ::inline]])]]
+                        [:tuple [:= "Cookie"] [:or
+                                               [:tuple [:= "Percent"] :int]
+                                               [:catn [:label [:= "Absolute"]] [:current :int] [:total :int]]]]
+                        [:tuple [:= "Latex_Fragment"] latex-fragment-schema]
+                        [:tuple [:= "Macro"] [:map
+                                              [:name :string]
+                                              [:arguments [:sequential :string]]]]
+                        [:tuple [:= "Entity"] [:map
+                                               [:name :string]
+                                               [:latex :string]
+                                               [:latex_mathp :boolean]
+                                               [:html :string]
+                                               [:ascii :string]
+                                               [:unicode :string]]]
+                        [:tuple [:= "Timestamp"] [:or
+                                                  [:tuple [:= "Scheduled"] [:ref ::timestamp]]
+                                                  [:tuple [:= "Deadline"] [:ref ::timestamp]]
+                                                  [:tuple [:= "Date"] [:ref ::timestamp]]
+                                                  [:tuple [:= "Closed"] [:ref ::timestamp]]
+                                                  [:tuple [:= "Clock"] [:or
+                                                                        [:tuple [:= "Started"] [:ref ::timestamp]]
+                                                                        [:tuple [:= "Stopped"] [:ref ::time-range]]]]
+                                                  [:tuple [:= "Range"] [:ref ::time-range]]]]
+                        [:tuple [:= "Radio_Target"] :string]
+                        [:tuple [:= "Export_Snippet"] :string :string]
+                        [:tuple [:= "Inline_Source_Block"] [:map
+                                                            [:language :string]
+                                                            [:options :string]
+                                                            [:code :string]]]
+                        [:tuple [:= "Email"] [:map
+                                              [:local_part :string]
+                                              [:domain :string]]]
+                        [:tuple [:= "Inline_Hiccup"] :string]
+                        [:tuple [:= "Inline_Html"] :string]]}}
+   ::inline])
+
+(def ^:private list-item-schema
+  [:map
+   [:content [:sequential [:ref ::block]]]
+   [:items [:sequential [:ref ::list-item]]]
+   (field-optional-and-maybe-nil
+    :number :int)
+   [:name [:sequential [:ref ::inline]]]
+   (field-optional-and-maybe-nil
+    :checkbox :boolean)
+   [:indent :int]
+   [:ordered :boolean]])
+
+(def ^:private heading-schema
+  [:map
+   [:title [:sequential [:ref ::inline]]]
+   [:tags [:sequential :string]]
+   (field-optional-and-maybe-nil
+    :marker :string)
+   [:level :int]
+   (field-optional-and-maybe-nil
+    :numbering [:sequential :int])
+   (field-optional-and-maybe-nil
+    :priority :string)
+   [:anchor :string]
+   [:meta :map]
+   (field-optional-and-maybe-nil
+    :size :int)])
+
+(def block-ast-schema
+  [:schema {:registry {::inline inline-ast-schema
+                       ::list-item list-item-schema
+                       ::block
+                       [:or
+                        [:tuple [:= "Paragraph"] [:sequential [:ref ::inline]]]
+                        [:tuple [:= "Paragraph_Sep"] :int]
+                        [:tuple [:= "Heading"] heading-schema]
+                        [:tuple [:= "List"] [:sequential [:ref ::list-item]]]
+                        [:tuple [:= "Directive"] :string :string]
+                        [:tuple [:= "Results"]]
+                        [:tuple [:= "Example"] [:sequential :string]]
+                        [:tuple [:= "Src"] [:map
+                                            [:lines [:sequential :string]]
+                                            (field-optional-and-maybe-nil
+                                             :language :string)
+                                            (field-optional-and-maybe-nil
+                                             :options [:sequential :string])
+                                            [:pos_meta pos-schema]]]
+                        [:tuple [:= "Quote"] [:sequential [:ref ::block]]]
+                        [:catn
+                         [:label [:= "Export"]]
+                         [:type :string]
+                         [:options [:maybe [:sequential :string]]]
+                         [:content :string]]
+                        [:tuple [:= "CommentBlock"] [:sequential :string]]
+                        [:catn
+                         [:label [:= "Custom"]]
+                         [:type :string]
+                         [:options [:maybe :string]]
+                         [:result [:sequential [:ref ::block]]]
+                         [:content :string]]
+                        [:tuple [:= "Latex_Fragment"] latex-fragment-schema]
+                        [:catn
+                         [:label [:= "Latex_Environment"]]
+                         [:name :string]
+                         [:options [:maybe :string]]
+                         [:content :string]]
+                        [:tuple [:= "Displayed_Math"] :string]
+                        [:tuple [:= "Drawer"] :string [:sequential :string]]
+                        [:tuple [:= "Property_Drawer"]
+                         [:sequential
+                          [:catn [:k :string] [:v :string] [:other-info [:sequential [:ref ::inline]]]]]]
+                        [:tuple [:= "Footnote_Definition"] :string [:sequential [:ref ::inline]]]
+                        [:tuple [:= "Horizontal_Rule"]]
+                        [:tuple [:= "Table"]
+                         [:map
+                          (field-optional-and-maybe-nil
+                           :header [:sequential [:sequential [:ref ::inline]]])
+                          [:groups [:sequential [:sequential [:sequential [:sequential [:ref ::inline]]]]]]
+                          [:col_groups [:sequential :int]]]]
+                        [:tuple [:= "Comment"] :string]
+                        [:tuple [:= "Raw_Html"] :string]
+                        [:tuple [:= "Hiccup"] :string]
+
+                        ;; this block type is not from mldoc,
+                        ;; but from `logseq.graph-parser.mldoc/collect-page-properties`
+                        [:tuple [:= "Properties"] [:sequential :any]]]}}
+   ::block])
+
+(def block-ast-with-pos-coll-schema
+  [:sequential [:cat block-ast-schema [:maybe pos-schema]]])
+
+(def block-ast-coll-schema
+  [:sequential block-ast-schema])

+ 10 - 9
deps/graph-parser/src/logseq/graph_parser/util.cljs

@@ -83,8 +83,7 @@
   [s]
   (and (string? s)
        (try
-         (js/URL. s)
-         true
+         (not (contains? #{nil "null"} (.-origin (js/URL. s))))
          (catch :default _e
            false))))
 
@@ -191,7 +190,7 @@
     (keyword format)))
 
 (defn path->file-name
-  ;; Only for interal paths, as they are converted to POXIS already
+  ;; Only for internal paths, as they are converted to POXIS already
   ;; https://github.com/logseq/logseq/blob/48b8e54e0fdd8fbd2c5d25b7f1912efef8814714/deps/graph-parser/src/logseq/graph_parser/extract.cljc#L32
   ;; Should be converted to POXIS first for external paths
   [path]
@@ -255,12 +254,14 @@
     (legacy-title-parsing file-name-body)))
 
 (defn safe-read-string
-  [content]
-  (try
-    (reader/read-string content)
-    (catch :default e
-      (log/error :parse/read-string-failed e)
-      {})))
+  ([content]
+   (safe-read-string {} content))
+  ([opts content]
+   (try
+     (reader/read-string opts content)
+     (catch :default e
+       (log/error :parse/read-string-failed e)
+       {}))))
 
 ;; Copied from Medley
 ;; https://github.com/weavejester/medley/blob/d1e00337cf6c0843fb6547aadf9ad78d981bfae5/src/medley/core.cljc#L22

+ 81 - 0
deps/graph-parser/test/logseq/graph_parser/block_test.cljs

@@ -1,6 +1,10 @@
 (ns logseq.graph-parser.block-test
   (:require [logseq.graph-parser.block :as gp-block]
             [logseq.graph-parser.mldoc :as gp-mldoc]
+            [logseq.graph-parser :as graph-parser]
+            [logseq.db :as ldb]
+            [logseq.graph-parser.util.block-ref :as block-ref]
+            [datascript.core :as d]
             [cljs.test :refer [deftest are testing is]]))
 
 (defn- extract-properties
@@ -13,6 +17,31 @@
     properties)
    user-config))
 
+(deftest test-fix-duplicate-id
+  (are [x y]
+      (let [result (gp-block/fix-duplicate-id x)]
+        (and (:uuid result)
+             (not= (:uuid x) (:uuid result))
+             (= (select-keys result
+                             [:properties :content :properties-text-values :properties-order]) y)))
+    {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :markdown, :meta {:start_pos 51, :end_pos 101}, :macros [], :unordered true, :content "bar\nid:: 63f199bc-c737-459f-983d-84acfcda14fe", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]}
+    {:properties {},
+     :content "bar",
+     :properties-text-values {},
+     :properties-order []}
+
+    {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :org, :meta {:start_pos 51, :end_pos 101}, :macros [], :unordered true, :content "bar\n:id: 63f199bc-c737-459f-983d-84acfcda14fe", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]}
+    {:properties {},
+     :content "bar",
+     :properties-text-values {},
+     :properties-order []}
+
+    {:properties {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :tags [], :format :markdown, :meta {:start_pos 51, :end_pos 101}, :macros [], :unordered true, :content "bar\n  \n  id:: 63f199bc-c737-459f-983d-84acfcda14fe\nblock body", :properties-text-values {:id "63f199bc-c737-459f-983d-84acfcda14fe"}, :level 1, :uuid #uuid "63f199bc-c737-459f-983d-84acfcda14fe", :properties-order [:id]}
+    {:properties {},
+     :content "bar\nblock body",
+     :properties-text-values {},
+     :properties-order []}))
+
 (deftest test-extract-properties
   (are [x y] (= (:properties (extract-properties x {})) y)
        ;; Built-in properties
@@ -80,3 +109,55 @@
              [["tags" "[[foo]], [[bar]]"] ["background-color" "#008000"]]
                                          {:property-pages/enabled? true})))
         "Only editable linkable built-in properties have page-refs in property values")))
+
+(defn find-block-for-content
+  [db content]
+  (->> (d/q '[:find (pull ?b [* {:block/refs [:block/uuid]}])
+              :in $ ?content
+              :where [?b :block/content ?content]]
+            db
+            content)
+       (map first)
+       first))
+
+(deftest refs-from-block-refs
+  (let [conn (ldb/start-conn)
+        id "63f528da-284a-45d1-ac9c-5d6a7435f6b4"
+        block (str "A block\nid:: " id)
+        block-ref-via-content (str "Link to " (block-ref/->block-ref id))
+        block-ref-via-block-properties (str "B block\nref:: " (block-ref/->block-ref id))
+        body (str "- " block "\n- " block-ref-via-content "\n- " block-ref-via-block-properties)]
+    (graph-parser/parse-file conn "foo.md" body {})
+
+    (testing "Block refs in blocks"
+      (is (= [{:block/uuid (uuid id)}]
+             (:block/refs (find-block-for-content @conn block-ref-via-content)))
+          "Block that links to a block via paragraph content has correct block ref")
+
+      (is (contains?
+           (set (:block/refs (find-block-for-content @conn block-ref-via-block-properties)))
+           {:block/uuid (uuid id)})
+          "Block that links to a block via block properties has correct block ref"))
+
+    (testing "Block refs in pre-block"
+      (let [block-ref-via-page-properties (str "page-ref:: " (block-ref/->block-ref id))]
+        (graph-parser/parse-file conn "foo2.md" block-ref-via-page-properties {})
+        (is (contains?
+             (set (:block/refs (find-block-for-content @conn block-ref-via-page-properties)))
+             {:block/uuid (uuid id)})
+            "Block that links to a block via page properties has correct block ref")))))
+
+(deftest timestamp-blocks
+  (let [conn (ldb/start-conn)
+        deadline-block "do something\nDEADLINE: <2023-02-21 Tue>"
+        scheduled-block "do something else\nSCHEDULED: <2023-02-20 Mon>"
+        body (str "- " deadline-block "\n- " scheduled-block)]
+    (graph-parser/parse-file conn "foo.md" body {})
+
+    (is (= 20230220
+           (:block/scheduled (find-block-for-content @conn scheduled-block)))
+        "Scheduled block has correct block attribute and value")
+
+    (is (= 20230221
+           (:block/deadline (find-block-for-content @conn deadline-block)))
+        "Deadline block has correct block attribute and value")))

+ 7 - 0
deps/graph-parser/test/logseq/graph_parser/util_test.cljs

@@ -28,3 +28,10 @@
        "/root/Documents/audio" nil
        "/root/Documents/audio." nil
        "special/characters/aäääöüß.7z" "7z"))
+
+(deftest url?
+  (are [x y]
+       (= (gp-util/url? x) y)
+       "http://logseq.com" true
+       "prop:: value" false
+       "a:" false))

+ 46 - 0
deps/graph-parser/test/logseq/graph_parser_test.cljs

@@ -376,3 +376,49 @@
                   (map (comp :block/name first))
                   (remove built-in-pages)
                   set))))))
+
+(deftest duplicated-ids
+  (testing "duplicated block ids in same file"
+    (let [conn (ldb/start-conn)
+          extract-block-ids (atom #{})
+          parse-opts {:extract-options {:extract-block-ids extract-block-ids}}
+          block-id #uuid "63f199bc-c737-459f-983d-84acfcda14fe"]
+      (graph-parser/parse-file conn
+                               "foo.md"
+                               "- foo
+id:: 63f199bc-c737-459f-983d-84acfcda14fe
+- bar
+id:: 63f199bc-c737-459f-983d-84acfcda14fe
+"
+                               parse-opts)
+      (let [blocks (:block/_parent (d/entity @conn [:block/name "foo"]))]
+        (is (= 2 (count blocks)))
+        (is (= 1 (count (filter #(= (:block/uuid %) block-id) blocks)))))))
+
+  (testing "duplicated block ids in multiple files"
+    (let [conn (ldb/start-conn)
+          extract-block-ids (atom #{})
+          parse-opts {:extract-options {:extract-block-ids extract-block-ids}}
+          block-id #uuid "63f199bc-c737-459f-983d-84acfcda14fe"]
+      (graph-parser/parse-file conn
+                               "foo.md"
+                               "- foo
+id:: 63f199bc-c737-459f-983d-84acfcda14fe
+bar
+- test"
+                               parse-opts)
+      (graph-parser/parse-file conn
+                               "bar.md"
+                               "- bar
+id:: 63f199bc-c737-459f-983d-84acfcda14fe
+bar
+- test
+"
+                               parse-opts)
+      (is (= "foo"
+             (-> (d/entity @conn [:block/uuid block-id])
+                 :block/page
+                 :block/name)))
+      (let [bar-block (first (:block/_parent (d/entity @conn [:block/name "bar"])))]
+        (is (some? (:block/uuid bar-block)))
+        (is (not= (:block/uuid bar-block) block-id))))))

+ 1 - 1
docs/accessibility.md

@@ -10,7 +10,7 @@
 	- We can also provide alternative options in order to conform with AAA standards. For instance, our default themes can aim for AA, but we can provide a high-contrast theme that aims for AAA. Providing [alternative versions](https://www.w3.org/WAI/GL/2007/05/alternate-versions.html) with different levels of conformance is permitted according to WCAG, if there is an accessible way to reach those alternatives.
 - ## Simple development guidelines
 	- Use semantically correct markup whenever possible. Every time you are about to decide which html tag you are going to use, choose the one that behaves the way you want it. For example, let's say you want to create an element that looks like plain text, but triggers an action on click. Usually, the best approach would be to create a `<button>` and make it look like a `<span>` using css. If you use a `span`, you will also have to override other html attributes like `tabindex` and `role` to make the element behave like a button. This is almost always a bad sign, and should be avoided. If you use the appropriate html element, the browser will be able to properly handle it.
-	- Do not skip headings. People who use screen readers and a keyboard to navigate through the app, use the headings structure to quickly jump to areas of interest. Skipping headings to visually conform with the design, makes this hard for them. If you want to create a heading tha looks like an `<h4>` but is in terms of document structure an `<h2>`, use the latter and make it look like an `<h4>`.
+	- Do not skip headings. People who use screen readers and a keyboard to navigate through the app, use the headings structure to quickly jump to areas of interest. Skipping headings to visually conform with the design, makes this hard for them. If you want to create a heading that looks like an `<h4>` but is in terms of document structure an `<h2>`, use the latter and make it look like an `<h4>`.
 	- A more [in-depth guide about HTML and accessibility](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/HTML).
 - ## Advanced concepts
 	- Focus management is extremely important for [keyboard navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Understanding_WCAG/Keyboard). Focusable elements can help people with motor disabilities navigate. [Focus Order](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-focus-order.html) plays an important role in this.

+ 3 - 3
docs/contributing-to-translations.md

@@ -84,9 +84,9 @@ Keys with duplicate values found:
 
 ## Fix Mistakes
 
-Sometimes, we typo the translation key. If that happens, the github CI step of
-`bb lang:invalid-translations` will detect this error and helpfully show you
-what was typoed.
+Sometimes, we typo a translation key or forget to use it. If this happens,
+the github CI step of `bb lang:validate-translations` will detect these errors
+and tell you what's wrong.
 
 ## Add a Language
 

+ 16 - 0
docs/dev-practices.md

@@ -77,6 +77,22 @@ error if it detects an invalid query.
 Our translations can be configured incorrectly. We can catch some of these
 mistakes [as noted here](./contributing-to-translations.md#fix-mistakes).
 
+### Spell Checker
+
+We use [typos](https://github.com/crate-ci/typos) to spell check our source code.
+
+To install it locally and use it:
+
+```sh
+$ brew install typos-cli
+# Catch any errors
+$ typos
+# Fix errors
+$ typos -w
+```
+
+To configure it e.g. for dealing with false positives, see `typos.toml`.
+
 ## Testing
 
 We have unit, performance and end to end tests.

+ 6 - 6
docs/develop-logseq-on-windows.md

@@ -6,12 +6,12 @@ This is a guide on creating Logseq development environment on Windows with `Powe
 ## Pre-requisites
 * Ensure `Set-ExecutionPolicy Unrestricted` (or other equivalent)
 * Good network connection. Here's [An example of setting up proxy in PowerShell](#an-example-of-setting-up-proxy-in-powershell)
-* Node.js 16.x
+* Node.js 18.x
 * Clojure (follow this [Guidance](https://clojure.org/guides/getting_started#_installation_on_windows))
 * JRE 8 (required for Clojure)
 * Visual Studio (required for desktop app)
 
-(updated 20220218. May confirm via JAVA_VERSION and NODE_VERSION in [THIS FILE](https://github.com/logseq/logseq/blob/master/.github/workflows/build.yml))
+(updated 20230221. May confirm via JAVA_VERSION and NODE_VERSION in [THIS FILE](https://github.com/logseq/logseq/blob/master/.github/workflows/build.yml))
 
 ### An example of installing pre-requisites on Windows
 * Install [Chocolatey](https://chocolatey.org/)
@@ -19,10 +19,10 @@ This is a guide on creating Logseq development environment on Windows with `Powe
 * Install NVM for Windows, Node.js, and Yarn
   ```
   choco install nvm
-  nvm install 16.13 (or whatever version)
-  nvm use 16.13
+  nvm install 18.12.0 (or whatever version)
+  nvm use 18.12.0
   npm install -g yarn
-  nvm use 16.13
+  nvm use 18.12.0
   ```
 * Install [clj-on-windows](https://github.com/clojure/tools.deps.alpha/wiki/clj-on-Windows)
 
@@ -30,7 +30,7 @@ Congrats! The pre-requisites are ready.
 
 ## Set-up development environment (web app)
 
-The basic idea is replacing the `clojure` commands in [package.json](https://github.com/logseq/logseq/blob/master/package.json) to `clj`.  
+The basic idea is replacing the `clojure` commands in [package.json](https://github.com/logseq/logseq/blob/master/package.json) to `clj`.
 Go to your cloned Logseq repo. Then install dependencies, execute the `clj` equivalent of `yarn watch`. Refer [THIS](#an-example-of-setting-up-proxy-in-powershell) if you want to setup proxy in `PowerShell`.
 
 * Copy files in `resources` to `static`

+ 15 - 0
docs/develop-logseq.md

@@ -24,6 +24,21 @@ yarn watch
 
 Then open the browser <http://localhost:3001>.
 
+### REPL setup
+
+#### VSCode + Calva
+With ```yarn watch``` running, it should prints ``shadow-cljs - nREPL server started on port 8701``
+
+You may connect to the nREPL server with:
+
+``cmd + shift + p`` -> ``Calva: Connect to a Running REPL Server in the Project`` -> ``logseq`` -> ``shadow-cljs``->``:app`` ->``localhost:8701``
+
+(change ``:app`` to ``:electron`` if you want to connect to the main thread of the Electron app)
+
+Open a dev environment (Browser dev app on ``localhost:3000`` or Desktop dev app), then you can play REPL on the current editing file:
+
+``cmd + shift + p`` -> ``Calva: Load/Evaluate Current File and its Requires/Dependencies``
+
 ### Production Build
 
 ```bash

+ 122 - 122
docs/issue-labels.md

@@ -1,123 +1,123 @@
-|           Label            |                         Description                          |
-| :------------------------: | :----------------------------------------------------------: |
-| `:status/automatic-stale`  | stale or no longer  relevant.  Added automatically by  GH Stale Action |
-|        `:type/bug`         |         Something isn't working. Affects daily use.          |
-|      `:type/bug-fix`       |                   Related to fixing a bug                    |
-|        `:type/dev`         |                 Related to development tasks                 |
-|    `:type/enhancement`     | Enhancement to Logseq. Does not affect the overall basic use |
-|      `:type/feature`       |                         New feature                          |
-|  `:type/feature-request`   | Feature requests are in Logseq forum https://discuss.logseq.com/ |
-|        `:type/hold`        | PR or issue is temporarily paused and will be revisited later |
-|    `:type/performance`     |           Performance related, speed or cpu usage.           |
-|      `:type/question`      | Not bug. Frequently asked question would be collected in Logseq Forum's FAQ section |
-|     `:type/regression`     |                Problem introduced by a change                |
-|     `:type/releasing`      |                Related to the release process                |
-|      `accessibility`       | Related to improving accessibility for users with disabilities |
-|          `assets`          |          Related to project assets, such as images           |
-|    `awaiting-response`     |       Issue will be closed if a reply is not received        |
-|        `block-ref`         |                 Related to block references                  |
-|     `breaking-change`      |  Change that breaks existing functionality or compatibility  |
-|         `browser`          |               Related to browser compatibility               |
-|          `build`           |        Related to building and development of Logseq         |
-|    `can-be-reproduced`     | Can be reliably observed to occur under specific conditions  |
-|           `card`           |             Related to Logseq Flashcards feature             |
-|          `chore`           |                 Related to maintenance tasks                 |
-|            `ci`            |              Related to continuous integration               |
-|           `css`            |                    Related to Logseq CSS                     |
-|      `data-stability`      |                  Related to data stability                   |
-|       `dependencies`       |         Pull requests that update a dependency file          |
-|         `desktop`          |               Related to desktop compatibility               |
-|          `docker`          |                      Related to Docker                       |
-|           `draw`           |       Related to drawing or diagramming functionality        |
-|        `duplicate`         |                       Duplicated issue                       |
-|          `editor`          |                 Related to the Logseq editor                 |
-|   `editor:autocomplete`    |      Related to the autocomplete feature in the editor       |
-|       `editor:code`        |         Related to codeblocks feature in the editor          |
-|    `editor:copy-paste`     |        Related to copy & paste actions in the editor         |
-|   `editor:lazy-loading`    |            Related to lazy loading in the editor             |
-|      `editor:popups`       |               Related to popups in the editor                |
-|     `editor:undo-redo`     |         Related to undo & redo actions in the editor         |
-|         `electron`         |                Related to Electron framework                 |
-|        `encryption`        |                    Related to encryption                     |
-|     `estimate: large`      |             Require major refactor of a feature              |
-|     `estimate: medium`     |                    Medium effort required                    |
-|     `estimate: small`      |       Small effort required, maybe a few lines of code       |
-|   `estimate: very large`   | A project level feature (as large as the projects listed in our roadmap) |
-|          `export`          |            Related to exporting data from Logseq             |
-|         `feature`          |                   New feature (duplicate)                    |
-|        `filenames`         |                    Related to file names                     |
-|         `firefox`          |               Related to Firefox compatibility               |
-|           `fix`            |             Related to fixing a bug or an issue              |
-|   `fixed-next-release ✅`   |            Fixed and will be in the next release             |
-|     `format:markdown`      |                  Related to Markdown format                  |
-|     `format:org-mode`      |                  Related to Org mode format                  |
-|       `from:in-app`        |             Reported from inside the application             |
-|            `fs`            |                  Related to the file system                  |
-|           `git`            |                Related to Git version control                |
-|     `good first issue`     |                      Good for newcomers                      |
-|          `graph`           |               Related to Logseq graph feature                |
-|       `help-wanted`        |              Extra attention or help is needed               |
-|        `hierarchy`         |                  Related to pages hierarchy                  |
-|           `i18n`           |               Related to internationalization                |
-|          `import`          |    Related to importing data or functionality into Logseq    |
-|       `input-method`       | Related to input methods, such as keyboard layouts or handwriting recognition |
-|       `installation`       |          Related to installation or setup of Logseq          |
-|   `integration:dropbox`    |             Related to integration with Dropbox              |
-|    `integration:gdrive`    |           Related to integration with Google Drive           |
-| `integration:icloud-drive` |           Related to integration with iCloud Drive           |
-|   `integration:nutstore`   |              Related to integration with 坚果云              |
-|    `integration:other`     |  Related to integration with other services or applications  |
-|    `integration:zotero`    |              Related to integration with Zotero              |
-|           `ipad`           |                Related to iPad compatibility                 |
-|         `journals`         |        Related to journaling or logging functionality        |
-|       `keymap:other`       |        Related to other key maps or keyboard layouts         |
-|         `lang:cjk`         |      Related to Chinese, Japanese, and Korean languages      |
-|      `lang:cyrillic`       |     Related to languages that use the Cyrillic alphabet      |
-|      `lang:estonian`       |               Related to the Estonian language               |
-|       `lang:latin1`        | Related to languages that use the ISO/IEC 8859-1 character set |
-|         `lang:rtl`         |              Related to right-to-left languages              |
-|       `left-sidebar`       |                 Related to the left sidebar                  |
-|         `logbook`          |                     Related to a logbook                     |
-|       `Logseq Sync`        |                    Related to Logseq Sync                    |
-|    `looking-for-review`    |   PRs that require review and feedback before being merged   |
-|           `math`           |     Related to mathematical formatting or functionality      |
-|          `mobile`          |       Related to mobile compatibility or functionality       |
-|      `mobile:android`      |      Related to Android compatibility or functionality       |
-|      `mobile:browser`      | Related to browser compatibility or functionality on mobile devices |
-|        `mobile:ios`        |        Related to iOS compatibility or functionality         |
-|       `multi-window`       |            Related to multi-window functionality             |
-|     `need-to-refactor`     |                  Code refactoring is needed                  |
-|    `need-to-reproduce`     |      More information is needed to reproduce the issue       |
-|         `os:linux`         |       Related to Linux compatibility or functionality        |
-|         `os:macos`         |       Related to macOS compatibility or functionality        |
-|         `os:other`         | Related to compatibility or functionality on other operating systems |
-|        `os:windows`        |      Related to Windows compatibility or functionality       |
-|         `page-ref`         |                  Related to page references                  |
-|          `parser`          |          Related to the Markdown or Org mode parser          |
-|           `pdf`            |          Related to PDF formatting or functionality          |
-|         `plugins`          |                  Related to Logseq plugins                   |
-|       `presentation`       |                 Related to presentation mode                 |
-|        `priority-A`        |                       Highest priority                       |
-|          `props`           |              Related to properties or metadata               |
-|       `props:alias`        |                  Related to alias property                   |
-|        `props:tags`        |                   Related to tags property                   |
-|       `props:title`        |                  Related to title property                   |
-|          `proxy`           |          Related to proxy settings or functionality          |
-|        `publishing`        |                Related to publishing feature                 |
-|          `query`           |                  Related to Logseq queries                   |
-|      `right-sidebar`       |                 Related to the right sidebar                 |
-|          `search`          |               Related to search functionality                |
-|         `settings`         |             Related to settings or configuration             |
-|        `shortcuts`         |                Related to keyboard shortcuts                 |
-|         `stale-PR`         |           PRs that need attention from maintainers           |
-|         `template`         |                 Related to templates feature                 |
-|          `themes`          |                 Related to themes or styling                 |
-|        `timestamp`         |     Related to timestamps or time-related functionality      |
-|          `touch`           |     Related to touch input or touchscreen compatibility      |
+|           Label            |                                               Description                                                |
+| :------------------------: | :------------------------------------------------------------------------------------------------------: |
+| `:status/automatic-stale`  |                  stale or no longer  relevant.  Added automatically by  GH Stale Action                  |
+|        `:type/bug`         |                               Something isn't working. Affects daily use.                                |
+|      `:type/bug-fix`       |                                         Related to fixing a bug                                          |
+|        `:type/dev`         |                                       Related to development tasks                                       |
+|    `:type/enhancement`     |                       Enhancement to Logseq. Does not affect the overall basic use                       |
+|      `:type/feature`       |                                               New feature                                                |
+|  `:type/feature-request`   |                     Feature requests are in Logseq forum https://discuss.logseq.com/                     |
+|        `:type/hold`        |                      PR or issue is temporarily paused and will be revisited later                       |
+|    `:type/performance`     |                                 Performance related, speed or cpu usage.                                 |
+|      `:type/question`      |           Not bug. Frequently asked question would be collected in Logseq Forum's FAQ section            |
+|     `:type/regression`     |                                      Problem introduced by a change                                      |
+|     `:type/releasing`      |                                      Related to the release process                                      |
+|      `accessibility`       |                      Related to improving accessibility for users with disabilities                      |
+|          `assets`          |                                Related to project assets, such as images                                 |
+|    `awaiting-response`     |                             Issue will be closed if a reply is not received                              |
+|        `block-ref`         |                                       Related to block references                                        |
+|     `breaking-change`      |                        Change that breaks existing functionality or compatibility                        |
+|         `browser`          |                                     Related to browser compatibility                                     |
+|          `build`           |                              Related to building and development of Logseq                               |
+|    `can-be-reproduced`     |                       Can be reliably observed to occur under specific conditions                        |
+|           `card`           |                                   Related to Logseq Flashcards feature                                   |
+|          `chore`           |                                       Related to maintenance tasks                                       |
+|            `ci`            |                                    Related to continuous integration                                     |
+|           `css`            |                                          Related to Logseq CSS                                           |
+|      `data-stability`      |                                        Related to data stability                                         |
+|       `dependencies`       |                               Pull requests that update a dependency file                                |
+|         `desktop`          |                                     Related to desktop compatibility                                     |
+|          `docker`          |                                            Related to Docker                                             |
+|           `draw`           |                             Related to drawing or diagramming functionality                              |
+|        `duplicate`         |                                             Duplicated issue                                             |
+|          `editor`          |                                       Related to the Logseq editor                                       |
+|   `editor:autocomplete`    |                            Related to the autocomplete feature in the editor                             |
+|       `editor:code`        |                               Related to codeblocks feature in the editor                                |
+|    `editor:copy-paste`     |                              Related to copy & paste actions in the editor                               |
+|   `editor:lazy-loading`    |                                  Related to lazy loading in the editor                                   |
+|      `editor:popups`       |                                     Related to popups in the editor                                      |
+|     `editor:undo-redo`     |                               Related to undo & redo actions in the editor                               |
+|         `electron`         |                                      Related to Electron framework                                       |
+|        `encryption`        |                                          Related to encryption                                           |
+|     `estimate: large`      |                                   Require major refactor of a feature                                    |
+|     `estimate: medium`     |                                          Medium effort required                                          |
+|     `estimate: small`      |                             Small effort required, maybe a few lines of code                             |
+|   `estimate: very large`   |                 A project level feature (as large as the projects listed in our roadmap)                 |
+|          `export`          |                                  Related to exporting data from Logseq                                   |
+|         `feature`          |                                         New feature (duplicate)                                          |
+|        `filenames`         |                                          Related to file names                                           |
+|         `firefox`          |                                     Related to Firefox compatibility                                     |
+|           `fix`            |                                   Related to fixing a bug or an issue                                    |
+|   `fixed-next-release ✅`   |                                  Fixed and will be in the next release                                   |
+|     `format:markdown`      |                                        Related to Markdown format                                        |
+|     `format:org-mode`      |                                        Related to Org mode format                                        |
+|       `from:in-app`        |                                   Reported from inside the application                                   |
+|            `fs`            |                                        Related to the file system                                        |
+|           `git`            |                                      Related to Git version control                                      |
+|     `good first issue`     |                                            Good for newcomers                                            |
+|          `graph`           |                                     Related to Logseq graph feature                                      |
+|       `help-wanted`        |                                    Extra attention or help is needed                                     |
+|        `hierarchy`         |                                        Related to pages hierarchy                                        |
+|           `i18n`           |                                     Related to internationalization                                      |
+|          `import`          |                          Related to importing data or functionality into Logseq                          |
+|       `input-method`       |              Related to input methods, such as keyboard layouts or handwriting recognition               |
+|       `installation`       |                                Related to installation or setup of Logseq                                |
+|   `integration:dropbox`    |                                   Related to integration with Dropbox                                    |
+|    `integration:gdrive`    |                                 Related to integration with Google Drive                                 |
+| `integration:icloud-drive` |                                 Related to integration with iCloud Drive                                 |
+|   `integration:nutstore`   |                                    Related to integration with 坚果云                                    |
+|    `integration:other`     |                        Related to integration with other services or applications                        |
+|    `integration:zotero`    |                                    Related to integration with Zotero                                    |
+|           `ipad`           |                                      Related to iPad compatibility                                       |
+|         `journals`         |                              Related to journaling or logging functionality                              |
+|       `keymap:other`       |                              Related to other key maps or keyboard layouts                               |
+|         `lang:cjk`         |                            Related to Chinese, Japanese, and Korean languages                            |
+|      `lang:cyrillic`       |                           Related to languages that use the Cyrillic alphabet                            |
+|      `lang:estonian`       |                                     Related to the Estonian language                                     |
+|       `lang:latin1`        |                      Related to languages that use the ISO/IEC 8859-1 character set                      |
+|         `lang:rtl`         |                                    Related to right-to-left languages                                    |
+|       `left-sidebar`       |                                       Related to the left sidebar                                        |
+|         `logbook`          |                                           Related to a logbook                                           |
+|       `Logseq Sync`        |                                          Related to Logseq Sync                                          |
+|    `looking-for-review`    |                         PRs that require review and feedback before being merged                         |
+|           `math`           |                           Related to mathematical formatting or functionality                            |
+|          `mobile`          |                             Related to mobile compatibility or functionality                             |
+|      `mobile:android`      |                            Related to Android compatibility or functionality                             |
+|      `mobile:browser`      |                   Related to browser compatibility or functionality on mobile devices                    |
+|        `mobile:ios`        |                              Related to iOS compatibility or functionality                               |
+|       `multi-window`       |                                  Related to multi-window functionality                                   |
+|     `need-to-refactor`     |                                        Code refactoring is needed                                        |
+|    `need-to-reproduce`     |                            More information is needed to reproduce the issue                             |
+|         `os:linux`         |                             Related to Linux compatibility or functionality                              |
+|         `os:macos`         |                             Related to macOS compatibility or functionality                              |
+|         `os:other`         |                   Related to compatibility or functionality on other operating systems                   |
+|        `os:windows`        |                            Related to Windows compatibility or functionality                             |
+|         `page-ref`         |                                        Related to page references                                        |
+|          `parser`          |                                Related to the Markdown or Org mode parser                                |
+|           `pdf`            |                                Related to PDF formatting or functionality                                |
+|         `plugins`          |                                        Related to Logseq plugins                                         |
+|       `presentation`       |                                       Related to presentation mode                                       |
+|        `priority-A`        |                                             Highest priority                                             |
+|          `props`           |                                    Related to properties or metadata                                     |
+|       `props:alias`        |                                        Related to alias property                                         |
+|        `props:tags`        |                                         Related to tags property                                         |
+|       `props:title`        |                                        Related to title property                                         |
+|          `proxy`           |                                Related to proxy settings or functionality                                |
+|        `publishing`        |                                      Related to publishing feature                                       |
+|          `query`           |                                        Related to Logseq queries                                         |
+|      `right-sidebar`       |                                       Related to the right sidebar                                       |
+|          `search`          |                                     Related to search functionality                                      |
+|         `settings`         |                                   Related to settings or configuration                                   |
+|        `shortcuts`         |                                      Related to keyboard shortcuts                                       |
+|         `stale-PR`         |                                 PRs that need attention from maintainers                                 |
+|         `template`         |                                       Related to templates feature                                       |
+|          `themes`          |                                       Related to themes or styling                                       |
+|        `timestamp`         |                           Related to timestamps or time-related functionality                            |
+|          `touch`           |                           Related to touch input or touchscreen compatibility                            |
 |   `translation-required`   | Translation is required. Please attach the translation in English. Or, the ticket will be closed at will |
-|         `upstream`         | Blocked by dependencies that are not maintained by the Logseq team |
-|        `url scheme`        |               Related to URL schemes or links                |
-|            `ux`            |           Related to user experience or usability            |
-|         `with-FAQ`         |   The question is added to the Logseq Forum's FAQ section    |
-|         `wontfix`          |              This issue or PR will not be fixed              |
-|    `🎨 :feat/whiteboard`    |             Related to whiteboard functionality              |
+|         `upstream`         |                    Blocked by dependencies that are not maintained by the Logseq team                    |
+|        `url scheme`        |                                     Related to URL schemes or links                                      |
+|            `ux`            |                                 Related to user experience or usability                                  |
+|         `with-FAQ`         |                         The question is added to the Logseq Forum's FAQ section                          |
+|         `wontfix`          |                                    This issue or PR will not be fixed                                    |
+|    `🎨 :feat/whiteboard`    |                                   Related to whiteboard functionality                                    |

+ 3 - 2
e2e-tests/basic.spec.ts

@@ -143,6 +143,7 @@ test('template', async ({ page, block }) => {
   await block.waitForBlocks(5)
 
   // NOTE: use delay to type slower, to trigger auto-completion UI.
+  await block.clickNext()
   await block.mustType('/template')
 
   await page.click('[title="Insert a created template here"]')
@@ -153,13 +154,13 @@ test('template', async ({ page, block }) => {
   await popupMenuItem.waitFor({ timeout: 2000 }) // wait for template search
   await popupMenuItem.click()
 
-  await block.waitForBlocks(8)
+  await block.waitForBlocks(9)
 })
 
 test('auto completion square brackets', async ({ page, block }) => {
   await createRandomPage(page)
 
-  // In this test, `type` is unsed instead of `fill`, to allow for auto-completion.
+  // In this test, `type` is unused instead of `fill`, to allow for auto-completion.
 
   // [[]]
   await block.mustType('This is a [', { toBe: 'This is a []' })

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

@@ -47,7 +47,7 @@ test('switch code editing mode', async ({ page }) => {
   await page.waitForTimeout(200) // editor unloading
   await page.press('.block-editor textarea', 'Escape')
   await page.waitForTimeout(200) // editor loading
-  // click position is estimated to be at the begining of the first line
+  // click position is estimated to be at the beginning of the first line
   await page.click('.CodeMirror pre', { position: { x: 1, y: 5 } })
   await page.waitForTimeout(200)
 

+ 28 - 107
e2e-tests/editor.spec.ts

@@ -1,6 +1,6 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { createRandomPage, enterNextBlock, systemModifier, IsMac } from './utils'
+import { createRandomPage, enterNextBlock, modKey } from './utils'
 import { dispatch_kb_events } from './util/keyboard-events'
 import * as kb_events from './util/keyboard-events'
 
@@ -78,7 +78,7 @@ test('create new page from bracketing text #4971', async ({ page, block }) => {
 
   await block.mustType(`[[${title}]]`)
 
-  await page.keyboard.press(systemModifier('Control+o'))
+  await page.keyboard.press(modKey + '+o')
 
   // Check page title equals to `title`
   await page.waitForTimeout(100)
@@ -91,7 +91,7 @@ test('create new page from bracketing text #4971', async ({ page, block }) => {
 test.skip('backspace and cursor position #4897', async ({ page, block }) => {
   await createRandomPage(page)
 
-  // Delete to previous block, and check cursor postion, with markup
+  // Delete to previous block, and check cursor position, with markup
   await block.mustFill('`012345`')
   await block.enterNext()
   await block.mustType('`abcdef', { toBe: '`abcdef`' }) // "`" auto-completes
@@ -111,7 +111,7 @@ test.skip('backspace and cursor position #4897', async ({ page, block }) => {
 test.skip('next block and cursor position', async ({ page, block }) => {
   await createRandomPage(page)
 
-  // Press Enter and check cursor postion, with markup
+  // Press Enter and check cursor position, with markup
   await block.mustType('abcde`12345', { toBe: 'abcde`12345`' }) // "`" auto-completes
   for (let i = 0; i < 7; i++) {
     await page.keyboard.press('ArrowLeft')
@@ -131,7 +131,7 @@ test(
   // cases should trigger [[]] #3251
   async ({ page, block }) => {
     // This test requires dev mode
-    test.skip(process.env.RELEASE === 'true', 'not avaliable for release version')
+    test.skip(process.env.RELEASE === 'true', 'not available for release version')
 
     for (let [idx, events] of [
       kb_events.win10_pinyin_left_full_square_bracket,
@@ -171,20 +171,12 @@ test('copy & paste block ref and replace its content', async ({ page, block }) =
   // FIXME: https://github.com/logseq/logseq/issues/7541
   await page.waitForTimeout(1000)
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+c')
-  } else {
-    await page.keyboard.press('Control+c')
-  }
+  await page.keyboard.press(modKey + '+c')
 
   await page.press('textarea >> nth=0', 'Enter')
   await block.waitForBlocks(2)
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+v')
-  } else {
-    await page.keyboard.press('Control+v')
-  }
+  await page.keyboard.press(modKey + '+v')
   await page.keyboard.press('Enter')
 
   // Check if the newly created block-ref has the same referenced content
@@ -198,11 +190,8 @@ test('copy & paste block ref and replace its content', async ({ page, block }) =
   await expect(page.locator('textarea >> nth=0')).not.toHaveValue('Some random text')
 
   // Trigger replace-block-reference-with-content-at-point
-  if (IsMac) {
-    await page.keyboard.press('Meta+Shift+r')
-  } else {
-    await page.keyboard.press('Control+Shift+r')
-  }
+  await page.keyboard.press(modKey + '+Shift+r')
+
   await expect(page.locator('textarea >> nth=0')).toHaveValue('Some random text')
   await block.escapeEditing()
 
@@ -218,11 +207,7 @@ test('copy and paste block after editing new block #5962', async ({ page, block
   await page.keyboard.press('Escape')
   await expect(page.locator('.ls-block.selected')).toHaveCount(1)
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+c', { delay: 10 })
-  } else {
-    await page.keyboard.press('Control+c', { delay: 10 })
-  }
+  await page.keyboard.press(modKey + '+c', { delay: 10 })
 
   await page.keyboard.press('Enter')
   await expect(page.locator('.ls-block.selected')).toHaveCount(0)
@@ -232,11 +217,7 @@ test('copy and paste block after editing new block #5962', async ({ page, block
 
   await block.mustType('Typed block')
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+v')
-  } else {
-    await page.keyboard.press('Control+v')
-  }
+  await page.keyboard.press(modKey + '+v')
   await expect(page.locator('text="Typed block"')).toHaveCount(1)
   await block.waitForBlocks(3)
 })
@@ -253,11 +234,7 @@ test('undo and redo after starting an action should not destroy text #6267', asy
   await page.keyboard.type('[[', { delay: 50 })
 
   await expect(page.locator(`[data-modal-name="page-search"]`)).toBeVisible()
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   await page.waitForTimeout(100)
 
   // Should close the action menu when we undo the action prompt
@@ -267,11 +244,7 @@ test('undo and redo after starting an action should not destroy text #6267', asy
   await expect(page.locator('text="text1"')).toHaveCount(1)
 
   // And it should keep what was undone as a redo action
-  if (IsMac) {
-    await page.keyboard.press('Meta+Shift+z')
-  } else {
-    await page.keyboard.press('Control+Shift+z')
-  }
+  await page.keyboard.press(modKey + '+Shift+z')
   await expect(page.locator('text="text2"')).toHaveCount(1)
 })
 
@@ -288,11 +261,7 @@ test('undo after starting an action should close the action menu #6269', async (
     await expect(page.locator(`[data-modal-name="${modalName}"]`)).toBeVisible()
 
     // Undo, removing "/today", and closing the action modal
-    if (IsMac) {
-      await page.keyboard.press('Meta+z')
-    } else {
-      await page.keyboard.press('Control+z')
-    }
+    await page.keyboard.press(modKey + '+z')
     await page.waitForTimeout(100)
     await expect(page.locator('text="/today"')).toHaveCount(0)
     await expect(page.locator(`[data-modal-name="${modalName}"]`)).not.toBeVisible()
@@ -567,31 +536,19 @@ test('should not erase typed text when expanding block quickly after typing #389
   await page.waitForTimeout(500)
   await page.type('textarea >> nth=0', ' then expand', { delay: 10 })
   // A quick cmd-down must not destroy the typed text
-  if (IsMac) {
-    await page.keyboard.press('Meta+ArrowDown')
-  } else {
-    await page.keyboard.press('Control+ArrowDown')
-  }
+  await page.keyboard.press(modKey + '+ArrowDown')
   await page.waitForTimeout(500)
   expect(await page.inputValue('textarea >> nth=0')).toBe(
     'initial text, then expand'
   )
 
   // First undo should delete the last typed information, not undo a no-op expand action
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   expect(await page.inputValue('textarea >> nth=0')).toBe(
     'initial text,'
   )
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   expect(await page.inputValue('textarea >> nth=0')).toBe(
     ''
   )
@@ -601,40 +558,28 @@ test('should keep correct undo and redo seq after indenting or outdenting the bl
   await createRandomPage(page)
 
   await block.mustFill("foo")
-  
+
   await page.keyboard.press("Enter")
   await expect(page.locator('textarea >> nth=0')).toHaveText("")
   await block.indent()
   await block.mustFill("bar")
   await expect(page.locator('textarea >> nth=0')).toHaveText("bar")
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   // should undo "bar" input
   await expect(page.locator('textarea >> nth=0')).toHaveText("")
-  if (IsMac) {
-    await page.keyboard.press('Shift+Meta+z')
-  } else {
-    await page.keyboard.press('Shift+Control+z')
-  }
+  await page.keyboard.press(modKey + '+Shift+z')
   // should redo "bar" input
   await expect(page.locator('textarea >> nth=0')).toHaveText("bar")
   await page.keyboard.press("Shift+Tab")
-  
+
   await page.keyboard.press("Enter")
   await expect(page.locator('textarea >> nth=0')).toHaveText("")
   // swap input seq
   await block.mustFill("baz")
   await block.indent()
 
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   // should undo indention
   await expect(page.locator('textarea >> nth=0')).toHaveText("baz")
   await page.keyboard.press("Shift+Tab")
@@ -646,40 +591,16 @@ test('should keep correct undo and redo seq after indenting or outdenting the bl
   await block.indent()
   await page.keyboard.type(" bbb")
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa bbb")
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa")
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa")
-  if (IsMac) {
-    await page.keyboard.press('Meta+z')
-  } else {
-    await page.keyboard.press('Control+z')
-  }
+  await page.keyboard.press(modKey + '+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("")
-  if (IsMac) {
-    await page.keyboard.press('Shift+Meta+z')
-  } else {
-    await page.keyboard.press('Shift+Control+z')
-  }
+  await page.keyboard.press(modKey + '+Shift+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa")
-  if (IsMac) {
-    await page.keyboard.press('Shift+Meta+z')
-  } else {
-    await page.keyboard.press('Shift+Control+z')
-  }
+  await page.keyboard.press(modKey + '+Shift+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa")
-  if (IsMac) {
-    await page.keyboard.press('Shift+Meta+z')
-  } else {
-    await page.keyboard.press('Shift+Control+z')
-  }
+  await page.keyboard.press(modKey + '+Shift+z')
   await expect(page.locator('textarea >> nth=0')).toHaveText("aaa bbb")
 })

+ 6 - 14
e2e-tests/fixtures.ts

@@ -9,14 +9,15 @@ let electronApp: ElectronApplication
 let context: BrowserContext
 let page: Page
 
-let repoName = randomString(10)
+// For testing special characters in graph name / path
+let repoName = "@" + randomString(10)
 let testTmpDir = path.resolve(__dirname, '../tmp')
 
 if (fs.existsSync(testTmpDir)) {
   fs.rmSync(testTmpDir, { recursive: true })
 }
 
-export let graphDir = path.resolve(testTmpDir, "e2e-test", repoName)
+export let graphDir = path.resolve(testTmpDir, "#e2e-test", repoName)
 
 // NOTE: This following is a console log watcher for error logs.
 // Save and print all logs when error happens.
@@ -26,21 +27,12 @@ const consoleLogWatcher = (msg: ConsoleMessage) => {
   const text = msg.text()
   logs += text + '\n'
 
-  // expect() will remember all arguments in memory,
-  // and the memory usage will grow *exponentially* in the number of output line.
-  // So we call expect() iff interesting pattern has already be found to avoid OOM.
-  const expectNotMatchWithCheck = (pattern: RegExp) => {
-    if (text.match(pattern)) {
-      expect(text, logs).not.toMatch(pattern)
-    }
-  }
-
-  expectNotMatchWithCheck(/^(Failed to|Uncaught)/)
+  expect(text, logs).not.toMatch(/^(Failed to|Uncaught)/)
 
   // youtube video
   // Error with Permissions-Policy header: Origin trial controlled feature not enabled: 'ch-ua-reduced'.
   if (!text.match(/^Error with Permissions-Policy header:/)) {
-    expectNotMatchWithCheck(/^Error/)
+    expect(text, logs).not.toMatch(/^Error/)
   }
 
   // NOTE: React warnings will be logged as error.
@@ -219,7 +211,7 @@ export const test = base.extend<LogseqFixtures>({
       },
       activeEditing: async (nth: number): Promise<void> => {
         await page.waitForSelector(`.ls-block >> nth=${nth}`, { timeout: 1000 })
-        // scroll, for isVisble test
+        // scroll, for isVisible test
         await page.$eval(`.ls-block >> nth=${nth}`, (element) => {
           element.scrollIntoView();
         });

+ 138 - 0
e2e-tests/fs.spec.ts

@@ -0,0 +1,138 @@
+import fsp from 'fs/promises';
+import path from 'path';
+import { expect } from '@playwright/test'
+import { test } from './fixtures';
+import { searchPage, captureConsoleWithPrefix, closeSearchBox, createPage, IsWindows } from './utils';
+
+test('create file on disk then delete', async ({ page, block, graphDir }) => {
+  // Since have to wait for file watchers
+  test.slow();
+
+  // Special page names: namespaced, chars require escaping, chars require unicode normalization, "%" chars, "%" with 2 hexdigests
+  const testCases = [
+    {pageTitle: "User:John", fileName: "User%3AJohn"},
+    // invalid url decode escaping as %ff is not parsable but match the common URL encode regex
+    {pageTitle: "#%ff", fileName: "#%ff"},
+    // valid url decode escaping
+    {pageTitle: "#%23", fileName: "#%2523"},
+    {pageTitle: "@!#%", fileName: "@!#%"},
+    {pageTitle: "aàáâ", fileName: "aàáâ"},
+    {pageTitle: "#%gggg", fileName: "#%gggg"}
+  ]
+  if (!IsWindows)
+    testCases.push({pageTitle: "User:Bob", fileName: "User:Bob"})
+
+  function getFullPath(fileName: string) {
+    return path.join(graphDir, "pages", `${fileName}.md`);
+  }
+
+  // Test putting files on disk
+  for (const {pageTitle, fileName} of testCases) {
+    // Put the file on disk
+    const filePath = getFullPath(fileName);
+    await fsp.writeFile(filePath, `- content for ${pageTitle}`);
+    await captureConsoleWithPrefix(page, "Parsing finished:", 5000)
+
+    // Check that the page is created
+    const results = await searchPage(page, pageTitle);
+    const firstResultRow = await results[0].innerText()
+    expect(firstResultRow).toContain(pageTitle);
+    expect(firstResultRow).not.toContain("New");
+    await closeSearchBox(page);
+  }
+
+  // Test removing files on disk
+  for (const {pageTitle, fileName} of testCases) {
+    // Remove the file on disk
+    const filePath = getFullPath(fileName);
+    await fsp.unlink(filePath);
+    await captureConsoleWithPrefix(page, "Delete page:", 5000);
+
+    // Test that the page is deleted
+    const results = await searchPage(page, pageTitle);
+    const firstResultRow = await results[0].innerText()
+    expect(firstResultRow).toContain("New");
+    await closeSearchBox(page);
+  }
+});
+
+test("Rename file on disk", async ({ page, block, graphDir }) => {
+  // Since have to wait for file watchers
+  test.slow();
+
+  const testCases = [
+    // Normal -> NameSpace
+    {pageTitle: "User:John", fileName: "User%3AJohn", 
+    newPageTitle: "User/John", newFileName: "User___John"},
+    // NameSpace -> Normal
+    {pageTitle: "#/%23", fileName: "#___%2523",
+    newPageTitle: "#%23", newFileName: "#%2523"}
+  ]
+  if (!IsWindows)
+    testCases.push({pageTitle: "User:Bob", fileName: "User:Bob",
+      newPageTitle: "User/Bob", newFileName: "User___Bob"})
+
+  function getFullPath(fileName: string) {
+    return path.join(graphDir, "pages", `${fileName}.md`);
+  }
+
+  // Test putting files on disk
+  for (const {pageTitle, fileName} of testCases) {
+    // Put the file on disk
+    const filePath = getFullPath(fileName);
+    await fsp.writeFile(filePath, `- content for ${pageTitle}`);
+    await captureConsoleWithPrefix(page, "Parsing finished:", 5000)
+
+    // Check that the page is created
+    const results = await searchPage(page, pageTitle);
+    const firstResultRow = await results[0].innerText()
+    expect(firstResultRow).toContain(pageTitle);
+    expect(firstResultRow).not.toContain("New");
+    await closeSearchBox(page);
+  }
+
+  // Test renaming files on disk
+  for (const {pageTitle, fileName, newPageTitle, newFileName} of testCases) {
+    // Rename the file on disk
+    const filePath = getFullPath(fileName);
+    const newFilePath = getFullPath(newFileName);
+    await fsp.rename(filePath, newFilePath);
+    await captureConsoleWithPrefix(page, "Parsing finished:", 5000);
+
+    // Test that the page is renamed
+    const results = await searchPage(page, newPageTitle);
+    const firstResultRow = await results[0].innerText()
+    expect(firstResultRow).toContain(newPageTitle);
+    expect(firstResultRow).not.toContain(pageTitle);
+    expect(firstResultRow).not.toContain("New");
+    await closeSearchBox(page);
+  }
+})
+
+test('special page names', async ({ page, block, graphDir }) => {
+  const testCases = [
+    {pageTitle: "User:John", fileName: "User%3AJohn"},
+    // FIXME: Logseq can't creat page starting with "#" in search panel
+    {pageTitle: "_#%ff", fileName: "_%23%25ff"},
+    {pageTitle: "_#%23", fileName: "_%23%2523"},
+    {pageTitle: "@!#%", fileName: "@!%23%"},
+    {pageTitle: "aàáâ", fileName: "aàáâ"},
+    {pageTitle: "_#%gggg", fileName: "_%23%gggg"}
+  ]
+
+  // Test putting files on disk
+  for (const {pageTitle, fileName} of testCases) {
+    // Create page in Logseq
+    await createPage(page, pageTitle)
+    const text = `content for ${pageTitle}`
+    await block.mustFill(text)
+    await page.keyboard.press("Enter")
+    
+    // Wait for the file to be created on disk
+    await page.waitForTimeout(2000);
+    // Validate that the file is created on disk with the content
+    const filePath = path.join(graphDir, "pages", `${fileName}.md`);
+    const fileContent = await fsp.readFile(filePath, "utf8");
+    expect(fileContent).toContain(text);
+  }
+});

+ 1 - 0
e2e-tests/headings.spec.ts

@@ -73,6 +73,7 @@ test('set heading of nested block to auto', async ({ page }) => {
 
 test('view nested block on a dedicated page', async ({ page }) => {
   await page.locator('span.bullet-container >> nth=1').click()
+  await page.waitForTimeout(200)
 
   expect(await page.locator('.ls-block .block-content >> nth=0').innerHTML()).toContain('<h1>bar</h1>')
 })

+ 4 - 12
e2e-tests/hotkey.spec.ts

@@ -1,13 +1,9 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { createRandomPage, newBlock, lastBlock, IsMac, IsLinux } from './utils'
+import { createRandomPage, newBlock, lastBlock, modKey, IsLinux } from './utils'
 
 test('open search dialog', async ({ page }) => {
-  if (IsMac) {
-    await page.keyboard.press('Meta+k')
-  } else {
-    await page.keyboard.press('Control+k')
-  }
+  await page.keyboard.press(modKey + '+k')
 
   await page.waitForSelector('[placeholder="Search or create page"]')
   await page.keyboard.press('Escape')
@@ -17,12 +13,8 @@ test('open search dialog', async ({ page }) => {
 test('insert link #3278', async ({ page }) => {
   await createRandomPage(page)
 
-  let hotKey = 'Control+l'
-  let selectAll = 'Control+a'
-  if (IsMac) {
-    hotKey = 'Meta+l'
-    selectAll = 'Meta+a'
-  }
+  let hotKey = modKey + '+l'
+  let selectAll = modKey + '+a'
 
   // Case 1: empty link
   await lastBlock(page)

+ 2 - 2
e2e-tests/logseq-url.spec.ts

@@ -9,7 +9,7 @@ test("Logseq URLs (same graph)", async ({ page, block }) => {
   let page_title = await createRandomPage(page)
   await block.mustFill(identify_text)
 
-  // paste current page's URL to another page, then redirect throught the URL
+  // paste current page's URL to another page, then redirect through the URL
   await page.click('.ui__dropdown-trigger .toolbar-dots-btn')
   await page.locator("text=Copy page URL").click()
   await createRandomPage(page)
@@ -26,7 +26,7 @@ test("Logseq URLs (same graph)", async ({ page, block }) => {
     expect(await cursor_locator.inputValue()).toBe(identify_text)
   }
 
-  // paste the identify block's URL to another page, then redirect throught the URL
+  // paste the identify block's URL to another page, then redirect through the URL
   await page.click('span.bullet >> nth=0', { button: "right" })
   await page.locator("text=Copy block URL").click()
   await createRandomPage(page)

+ 30 - 54
e2e-tests/page-search.spec.ts

@@ -1,20 +1,17 @@
 import { expect, Page } from '@playwright/test'
 import { test } from './fixtures'
 import { Block } from './types'
-import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastBlock, enterNextBlock } from './utils'
+import { modKey, createRandomPage, newBlock, newInnerBlock, randomString, lastBlock, enterNextBlock } from './utils'
+import { searchPage, closeSearchBox } from './util/search-modal'
 
 /***
  * Test alias features
- * Test search refering features
+ * Test search referring features
  * Consider diacritics
  ***/
 
- let hotkeyOpenLink = 'Control+o'
- let hotkeyBack = 'Control+['
- if (IsMac) {
-   hotkeyOpenLink = 'Meta+o'
-   hotkeyBack = 'Meta+['
- }
+let hotkeyOpenLink = modKey + '+o'
+let hotkeyBack = modKey + '+['
 
 test('Search page and blocks (diacritics)', async ({ page, block }) => {
   const rand = randomString(20)
@@ -23,7 +20,9 @@ test('Search page and blocks (diacritics)', async ({ page, block }) => {
   await createRandomPage(page)
 
   await block.mustType('[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-1', { delay: 10 })
-  await page.keyboard.press(hotkeyOpenLink)
+  await page.waitForTimeout(500)
+  await page.keyboard.press(hotkeyOpenLink, { delay: 10 })
+  await page.waitForTimeout(500)
 
   const pageTitle = page.locator('.page-title').first()
   expect(await pageTitle.innerText()).toEqual('Einführung in die Allgemeine Sprachwissenschaft' + rand)
@@ -39,18 +38,9 @@ test('Search page and blocks (diacritics)', async ({ page, block }) => {
   await page.keyboard.press(hotkeyBack)
 
   // check if diacritics are indexed
-  await page.click('#search-button')
-  await page.waitForSelector('[placeholder="Search or create page"]')
-  await page.type('[placeholder="Search or create page"]', 'Einführung in die Allgemeine Sprachwissenschaft' + rand, { delay: 10 })
-
-  await page.waitForTimeout(2000) // wait longer for search contents to render
-  // 2 blocks + 1 page + 1 page content
-  const searchResults = page.locator('#ui__ac-inner>div')
-  await expect(searchResults).toHaveCount(5) // 1 page + 2 block + 2 page content
-
-  await page.keyboard.press("Escape") // escape search box typing
-  await page.waitForTimeout(500)
-  await page.keyboard.press("Escape") // escape modal
+  const results = await searchPage(page, 'Einführung in die Allgemeine Sprachwissenschaft' + rand)
+  await expect(results.length).toEqual(5) // 1 page + 2 block + 2 page content
+  await closeSearchBox(page)
 })
 
 test('Search CJK', async ({ page, block }) => {
@@ -60,29 +50,22 @@ test('Search CJK', async ({ page, block }) => {
   await createRandomPage(page)
 
   await block.mustType('[[今日daytime进度条' + rand + ']] diacritic-block-1', { delay: 10 })
-  await page.keyboard.press(hotkeyOpenLink)
+  await page.waitForTimeout(500)
+  await page.keyboard.press(hotkeyOpenLink, { delay: 10 })
+  await page.waitForTimeout(500)
 
   const pageTitle = page.locator('.page-title').first()
   expect(await pageTitle.innerText()).toEqual('今日daytime进度条' + rand)
 
   await page.waitForTimeout(500)
 
-  // check if diacritics are indexed
-  await page.click('#search-button')
-  await page.waitForSelector('[placeholder="Search or create page"]')
-  await page.type('[placeholder="Search or create page"]', '进度', { delay: 10 })
-
-  await page.waitForTimeout(2000) // wait longer for search contents to render
-  // 2 blocks + 1 page + 1 page content
-  const searchResults = page.locator('#ui__ac-inner>div')
-  await expect(searchResults).toHaveCount(4) // 1 new page + 1 page + 1 block + 1 page content
-
-  await page.keyboard.press("Escape") // escape search box typing
-  await page.waitForTimeout(500)
-  await page.keyboard.press("Escape") // escape modal
+  // check if CJK are indexed
+  const results = await searchPage(page, '进度')
+  await expect(results.length).toEqual(4) // 1 page + 1 block + 1 page content
+  await closeSearchBox(page)
 })
 
-async function alias_test( block: Block, page: Page, page_name: string, search_kws: string[] ) {
+async function alias_test(block: Block, page: Page, page_name: string, search_kws: string[]) {
   await createRandomPage(page)
 
   const rand = randomString(10)
@@ -101,11 +84,11 @@ async function alias_test( block: Block, page: Page, page_name: string, search_k
   // the target page will contains the content in
   //   alias_test_content_1,
   //   alias_test_content_2, and
-  //   alias_test_content_3 sequentialy, to validate the target page state
-  await page.type('textarea >> nth=0', 'alias:: [[' + alias_name, {delay: 10})
-  await page.keyboard.press('Enter', {delay: 200}) // Enter for finishing selection
-  await page.keyboard.press('Enter', {delay: 200}) // double Enter for exit property editing
-  await page.keyboard.press('Enter', {delay: 200}) // double Enter for exit property editing
+  //   alias_test_content_3 sequentially, to validate the target page state
+  await page.type('textarea >> nth=0', 'alias:: [[' + alias_name, { delay: 10 })
+  await page.keyboard.press('Enter', { delay: 200 }) // Enter for finishing selection
+  await page.keyboard.press('Enter', { delay: 200 }) // double Enter for exit property editing
+  await page.keyboard.press('Enter', { delay: 200 }) // double Enter for exit property editing
   await page.waitForTimeout(200)
   await block.activeEditing(1)
   await page.type('textarea >> nth=0', alias_test_content_1)
@@ -117,7 +100,7 @@ async function alias_test( block: Block, page: Page, page_name: string, search_k
   // create alias ref in origin Page
   await block.activeEditing(0)
   await block.enterNext()
-  await page.type('textarea >> nth=0', '[[' + alias_name, {delay: 20})
+  await page.type('textarea >> nth=0', '[[' + alias_name, { delay: 20 })
   await page.keyboard.press('Enter') // Enter for finishing selection
   await page.waitForTimeout(100)
 
@@ -148,7 +131,7 @@ async function alias_test( block: Block, page: Page, page_name: string, search_k
   await newInnerBlock(page)
   await page.type('textarea >> nth=0', alias_test_content_3)
   page.keyboard.press(hotkeyBack)
-  
+
   await page.waitForNavigation()
   await block.escapeEditing()
   // clicking alias ref opening test
@@ -165,13 +148,8 @@ async function alias_test( block: Block, page: Page, page_name: string, search_k
   for (let kw of search_kws) {
     let kw_name = kw + ' alias ' + rand
 
-    await page.click('#search-button')
-    await page.waitForSelector('[placeholder="Search or create page"]')
-    await page.type('[placeholder="Search or create page"]', kw_name)
-    await page.waitForTimeout(500)
-
-    const results = await page.$$('#ui__ac-inner>div')
-    expect(results.length).toEqual(5) // page + block + alias property + page content
+    const results = await searchPage(page, kw_name)
+    await expect(results.length).toEqual(5) // page + block + alias property + page content
 
     // test search results
     expect(await results[0].innerText()).toContain("Alias -> " + target_name)
@@ -186,10 +164,8 @@ async function alias_test( block: Block, page: Page, page_name: string, search_k
     expect(await page.locator('.ls-block span.inline >> nth=2').innerHTML()).toBe(alias_test_content_3)
 
     // test search clicking (block)
-    await page.click('#search-button')
-    await page.waitForSelector('[placeholder="Search or create page"]')
-    await page.type('[placeholder="Search or create page"]', kw_name)
-    await page.waitForTimeout(500)
+    await searchPage(page, kw_name)
+
     page.click(":nth-match(.search-result, 3)")
     await page.waitForNavigation()
     await page.waitForSelector('.selected a.page-ref')

+ 1 - 1
e2e-tests/random.spec.ts

@@ -128,7 +128,7 @@ test.skip('Random editor operations', async ({ page, block }) => {
         await page.keyboard.press('Backspace', { delay: 50 })
       }
     } else if (op === "delete") {
-      // move text-cursor to begining
+      // move text-cursor to beginning
       // then press delete
       // then move text-cursor to the end
       await block.activeEditing(target)

+ 2 - 0
e2e-tests/sidebar.spec.ts

@@ -17,7 +17,9 @@ test('favorite item and recent item test', async ({ page }) => {
   // click from another page
   const another_page_name = await createRandomPage(page)
   expect(await page.innerText(':nth-match(.favorite-item a, 1)')).toBe(fav_page_name)
+  await page.waitForTimeout(500);
   await page.click(":nth-match(.favorite-item, 1)")
+  await page.waitForTimeout(500);
   expect(await page.innerText('.page-title .title')).toBe(fav_page_name)
 
   expect(await page.innerText(':nth-match(.recent-item a, 1)')).toBe(fav_page_name)

+ 42 - 0
e2e-tests/util/basic.ts

@@ -0,0 +1,42 @@
+// This file is used to store basic functions that are used in other utils
+// Should have no dependency on other utils
+
+import * as process from 'process'
+
+export const IsMac = process.platform === 'darwin'
+export const IsLinux = process.platform === 'linux'
+export const IsWindows = process.platform === 'win32'
+export const IsCI = process.env.CI === 'true'
+export const modKey = IsMac ? 'Meta' : 'Control'
+
+export function randomString(length: number) {
+  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+  let result = '';
+  const charactersLength = characters.length;
+  for (let i = 0; i < length; i++) {
+    result += characters.charAt(Math.floor(Math.random() * charactersLength));
+  }
+
+  return result;
+}
+
+export function randomLowerString(length: number) {
+  const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
+
+  let result = '';
+  const charactersLength = characters.length;
+  for (let i = 0; i < length; i++) {
+    result += characters.charAt(Math.floor(Math.random() * charactersLength));
+  }
+
+  return result;
+}
+
+export function randomInt(min: number, max: number): number {
+  return Math.floor(Math.random() * (max - min + 1) + min)
+}
+  
+export function randomBoolean(): boolean {
+  return Math.random() < 0.5;
+}

+ 63 - 0
e2e-tests/util/search-modal.ts

@@ -0,0 +1,63 @@
+import { Page, Locator, ElementHandle } from '@playwright/test'
+import { randomString } from './basic'
+
+export async function createRandomPage(page: Page) {
+    const randomTitle = randomString(20)
+  
+    // Click #search-button
+    await page.click('#search-button')
+    // Fill [placeholder="Search or create page"]
+    await page.fill('[placeholder="Search or create page"]', randomTitle)
+    // Click text=/.*New page: "new page".*/
+    await page.click('text=/.*New page: ".*/')
+    // Wait for h1 to be from our new page
+    await page.waitForSelector(`h1 >> text="${randomTitle}"`, { state: 'visible' })
+    // wait for textarea of first block
+    await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
+  
+    return randomTitle;
+  }
+  
+  export async function createPage(page: Page, page_name: string) {// Click #search-button
+    await page.click('#search-button')
+    // Fill [placeholder="Search or create page"]
+    await page.fill('[placeholder="Search or create page"]', page_name)
+    // Click text=/.*New page: "new page".*/
+    await page.click('text=/.*New page: ".*/')
+    // wait for textarea of first block
+    await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
+  
+    return page_name;
+  }
+  
+  export async function searchAndJumpToPage(page: Page, pageTitle: string) {
+    await page.click('#search-button')
+    await page.type('[placeholder="Search or create page"]', pageTitle)
+    await page.waitForSelector(`[data-page-ref="${pageTitle}"]`, { state: 'visible' })
+    page.click(`[data-page-ref="${pageTitle}"]`)
+    await page.waitForNavigation()
+    return pageTitle;
+  }
+  
+  /**
+   * type a search query into the search box
+   * stop at the point where search box shows up
+   * 
+   * @param page the pw page object
+   * @param query the search query to type into the search box
+   * @returns the HTML element for the search results ui
+   */
+  export async function searchPage(page: Page, query: string): Promise<ElementHandle<SVGElement | HTMLElement>[]>{
+    await page.click('#search-button')
+    await page.waitForSelector('[placeholder="Search or create page"]')
+    await page.type('[placeholder="Search or create page"]', query, { delay: 10 })
+    await page.waitForTimeout(2000) // wait longer for search contents to render
+  
+    return page.$$('#ui__ac-inner>div');
+  }
+  
+  export async function closeSearchBox(page: Page): Promise<void> {
+    await page.keyboard.press("Escape") // escape (potential) search box typing
+    await page.waitForTimeout(500)
+    await page.keyboard.press("Escape") // escape modal
+  }

+ 15 - 85
e2e-tests/utils.ts

@@ -1,76 +1,13 @@
 import { Page, Locator } from 'playwright'
 import { expect, ConsoleMessage } from '@playwright/test'
-import * as process from 'process'
-import { Block } from './types'
 import * as pathlib from 'path'
 
-export const IsMac = process.platform === 'darwin'
-export const IsLinux = process.platform === 'linux'
-export const IsWindows = process.platform === 'win32'
-export const IsCI = process.env.CI === 'true'
-
-export function randomString(length: number) {
-  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-
-  let result = '';
-  const charactersLength = characters.length;
-  for (let i = 0; i < length; i++) {
-    result += characters.charAt(Math.floor(Math.random() * charactersLength));
-  }
-
-  return result;
-}
-
-export function randomLowerString(length: number) {
-  const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
-
-  let result = '';
-  const charactersLength = characters.length;
-  for (let i = 0; i < length; i++) {
-    result += characters.charAt(Math.floor(Math.random() * charactersLength));
-  }
-
-  return result;
-}
-
-export async function createRandomPage(page: Page) {
-  const randomTitle = randomString(20)
-
-  // Click #search-button
-  await page.click('#search-button')
-  // Fill [placeholder="Search or create page"]
-  await page.fill('[placeholder="Search or create page"]', randomTitle)
-  // Click text=/.*New page: "new page".*/
-  await page.click('text=/.*New page: ".*/')
-  // Wait for h1 to be from our new page
-  await page.waitForSelector(`h1 >> text="${randomTitle}"`, { state: 'visible' })
-  // wait for textarea of first block
-  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
-
-  return randomTitle;
-}
-
-export async function createPage(page: Page, page_name: string) {// Click #search-button
-  await page.click('#search-button')
-  // Fill [placeholder="Search or create page"]
-  await page.fill('[placeholder="Search or create page"]', page_name)
-  // Click text=/.*New page: "new page".*/
-  await page.click('text=/.*New page: ".*/')
-  // wait for textarea of first block
-  await page.waitForSelector('textarea >> nth=0', { state: 'visible' })
-
-  return page_name;
-}
-
-
-export async function searchAndJumpToPage(page: Page, pageTitle: string) {
-  await page.click('#search-button')
-  await page.type('[placeholder="Search or create page"]', pageTitle)
-  await page.waitForSelector(`[data-page-ref="${pageTitle}"]`, { state: 'visible' })
-  page.click(`[data-page-ref="${pageTitle}"]`)
-  await page.waitForNavigation()
-  return pageTitle;
-}
+// TODO: The file should be a facade of utils in the /util folder
+// No more additional functions should be added to this file
+// Move the functions to the corresponding files in the /util folder
+// Criteria: If the same selector is shared in multiple functions, they should be in the same file
+export * from './util/basic'
+export * from './util/search-modal'
 
 /**
 * Locate the last block in the inner editor
@@ -233,22 +170,15 @@ export async function editFirstBlock(page: Page) {
   await page.click('.ls-block .block-content >> nth=0')
 }
 
-export function randomInt(min: number, max: number): number {
-  return Math.floor(Math.random() * (max - min + 1) + min)
-}
-
-export function randomBoolean(): boolean {
-  return Math.random() < 0.5;
-}
-
-export function systemModifier(shortcut: string): string {
-  if (IsMac) {
-    return shortcut.replace('Control', 'Meta')
-  } else {
-    return shortcut
-  }
-}
-
+/**
+ * Wait for a console message with a given prefix to appear, and return the full text of the message
+ * Or reject after a timeout
+ * 
+ * @param page 
+ * @param prefix - the prefix to look for
+ * @param timeout - the timeout in ms
+ * @returns the full text of the console message
+ */
 export async function captureConsoleWithPrefix(page: Page, prefix: string, timeout: number = 3000): Promise<string> {
   return new Promise((resolve, reject) => {
     let console_handler = (msg: ConsoleMessage) => {

+ 149 - 16
e2e-tests/whiteboards.spec.ts

@@ -1,6 +1,6 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
-import { IsMac } from './utils'
+import { modKey } from './utils'
 
 test('enable whiteboards', async ({ page }) => {
   await expect(page.locator('.nav-header .whiteboard')).toBeHidden()
@@ -77,39 +77,172 @@ test('draw a rectangle', async ({ page }) => {
   await page.mouse.move(bounds.x + 5, bounds.y + 5)
   await page.mouse.down()
 
-  await page.mouse.move(
-    bounds.x + bounds.width / 2,
-    bounds.y + bounds.height / 2
-  )
+  await page.mouse.move(bounds.x + 50, bounds.y + 50 )
   await page.mouse.up()
+  await page.keyboard.press('Escape')
 
-  await expect(
-    page.locator('.logseq-tldraw .tl-positioned-svg rect')
-  ).not.toHaveCount(0)
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(1)
+})
+
+test('undo the rectangle action', async ({ page }) => {
+  await page.keyboard.press(modKey + '+z')
+
+  await expect(page.locator('.logseq-tldraw .tl-positioned-svg rect')).toHaveCount(0)
+})
+
+test('redo the rectangle action', async ({ page }) => {
+  await page.keyboard.press(modKey + '+Shift+z')
+
+  await page.keyboard.press('Escape')
+  await page.waitForTimeout(100)
+
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(1)
 })
 
+test('clone the rectangle', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.mouse.move(bounds.x + 20, bounds.y + 20, {steps: 5})
+
+  await page.keyboard.down('Alt')
+  await page.mouse.down()
+
+  await page.mouse.move(bounds.x + 100, bounds.y + 100, {steps: 5})
+  await page.mouse.up()
+  await page.keyboard.up('Alt')
+
+  await expect(page.locator('.logseq-tldraw .tl-box-container')).toHaveCount(2)
+})
+
+test('connect rectangles with an arrow', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.keyboard.press('c')
+
+  await page.mouse.move(bounds.x + 20, bounds.y + 20)
+  await page.mouse.down()
+
+  await page.mouse.move(bounds.x + 100, bounds.y + 100, {steps: 5}) // will fail without steps
+  await page.mouse.up()
+  await page.keyboard.press('Escape')
+
+
+  await expect(page.locator('.logseq-tldraw .tl-line-container')).toHaveCount(1)
+})
 
 test('cleanup the shapes', async ({ page }) => {
-  if (IsMac) {
-    await page.keyboard.press('Meta+a')
-  } else {
-    await page.keyboard.press('Control+a')
-  }
+  await page.keyboard.press(`${modKey}+a`)
   await page.keyboard.press('Delete')
   await expect(page.locator('[data-type=Shape]')).toHaveCount(0)
 })
 
+test('create a block', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.keyboard.press('s')
+  await page.mouse.dblclick(bounds.x + 5, bounds.y + 5)
+  await page.waitForTimeout(100)
+
+  await page.keyboard.type('a')
+  await page.keyboard.press('Enter')
+
+
+  await expect(page.locator('.logseq-tldraw .tl-logseq-portal-container')).toHaveCount(1)
+})
+
+test('expand the block', async ({ page }) => {
+  await page.keyboard.press('Escape')
+  await page.click('.logseq-tldraw .tl-context-bar .tie-object-expanded ')
+  await page.waitForTimeout(100)
+
+  await expect(page.locator('.logseq-tldraw .tl-logseq-portal-container .tl-logseq-portal-header')).toHaveCount(1)
+})
+
+test('undo the expand action', async ({ page }) => {
+  await page.keyboard.press(modKey + '+z')
+
+  await expect(page.locator('.logseq-tldraw .tl-logseq-portal-container .tl-logseq-portal-header')).toHaveCount(0)
+})
+
+test('undo the block action', async ({ page }) => {
+  await page.keyboard.press(modKey + '+z')
+
+  await expect(page.locator('.logseq-tldraw .tl-logseq-portal-container')).toHaveCount(0)
+})
+
+test('copy/paste url to create an iFrame shape', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.keyboard.press('t')
+  await page.mouse.move(bounds.x + 5, bounds.y + 5)
+  await page.mouse.down()
+  await page.waitForTimeout(100)
+
+  await page.keyboard.type('https://logseq.com')
+  await page.keyboard.press(modKey + '+a')
+  await page.keyboard.press(modKey + '+c')
+  await page.keyboard.press('Escape')
+
+  await page.keyboard.press(modKey + '+v')
+
+  await expect( page.locator('.logseq-tldraw .tl-iframe-container')).toHaveCount(1)
+})
+
+test('copy/paste twitter status url to create a Tweet shape', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.keyboard.press('t')
+  await page.mouse.move(bounds.x + 5, bounds.y + 5)
+  await page.mouse.down()
+  await page.waitForTimeout(100)
+
+  await page.keyboard.type('https://twitter.com/logseq/status/1605224589046386689')
+  await page.keyboard.press(modKey + '+a')
+  await page.keyboard.press(modKey + '+c')
+  await page.keyboard.press('Escape')
+
+  await page.keyboard.press(modKey + '+v')
+
+  await expect( page.locator('.logseq-tldraw .tl-tweet-container')).toHaveCount(1)
+})
+
+test('copy/paste youtube video url to create a Youtube shape', async ({ page }) => {
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
+
+  await page.keyboard.press('t')
+  await page.mouse.move(bounds.x + 5, bounds.y + 5)
+  await page.mouse.down()
+  await page.waitForTimeout(100)
+
+  await page.keyboard.type('https://www.youtube.com/watch?v=hz2BacySDXE')
+  await page.keyboard.press(modKey + '+a')
+  await page.keyboard.press(modKey + '+c')
+  await page.keyboard.press('Escape')
+
+  await page.keyboard.press(modKey + '+v')
+
+  await expect(page.locator('.logseq-tldraw .tl-youtube-container')).toHaveCount(1)
+})
+
 test('zoom in', async ({ page }) => {
   await page.keyboard.press('Shift+0') // reset zoom
   await page.waitForTimeout(1500) // wait for the zoom animation to finish
-  await page.click('#tl-zoom-in')
+  await page.keyboard.press(`${modKey}++`)
+  await page.waitForTimeout(1500) // wait for the zoom animation to finish
   await expect(page.locator('#tl-zoom')).toContainText('125%')
 })
 
 test('zoom out', async ({ page }) => {
   await page.keyboard.press('Shift+0')
-  await page.waitForTimeout(1500)
-  await page.click('#tl-zoom-out')
+  await page.waitForTimeout(1500) // wait for the zoom animation to finish
+  await page.keyboard.press(`${modKey}+-`)
+  await page.waitForTimeout(1500) // wait for the zoom animation to finish
   await expect(page.locator('#tl-zoom')).toContainText('80%')
 })
 

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

@@ -515,7 +515,7 @@
 				INFOPLIST_FILE = App/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.8.16;
+				MARKETING_VERSION = 0.8.18;
 				OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
@@ -542,7 +542,7 @@
 				INFOPLIST_FILE = App/Info.plist;
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
-				MARKETING_VERSION = 0.8.16;
+				MARKETING_VERSION = 0.8.18;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -567,7 +567,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
-				MARKETING_VERSION = 0.8.16;
+				MARKETING_VERSION = 0.8.18;
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
@@ -594,7 +594,7 @@
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
 				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
-				MARKETING_VERSION = 0.8.16;
+				MARKETING_VERSION = 0.8.18;
 				MTL_FAST_MATH = YES;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
 				PRODUCT_NAME = "$(TARGET_NAME)";

+ 5 - 5
ios/App/App/FsWatcher.swift

@@ -45,7 +45,7 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
         call.resolve()
     }
 
-    public func recevedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
+    public func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
         // NOTE: Event in js {dir path content stat{mtime}}
         switch event {
         case .Unlink:
@@ -112,7 +112,7 @@ extension URL {
 // MARK: PollingWatcher
 
 public protocol PollingWatcherDelegate {
-    func recevedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?)
+    func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?)
 }
 
 public enum PollingWatcherEvent {
@@ -253,14 +253,14 @@ public class PollingWatcher {
             if let idx = self.metaDb.index(forKey: url) {
                 let (_, oldMeta) = self.metaDb.remove(at: idx)
                 if oldMeta != meta {
-                    self.delegate?.recevedNotification(url, .Change, meta)
+                    self.delegate?.receivedNotification(url, .Change, meta)
                 }
             } else {
-                self.delegate?.recevedNotification(url, .Add, meta)
+                self.delegate?.receivedNotification(url, .Add, meta)
             }
         }
         for url in self.metaDb.keys {
-            self.delegate?.recevedNotification(url, .Unlink, nil)
+            self.delegate?.receivedNotification(url, .Unlink, nil)
         }
         self.metaDb = newMetaDb
     }

+ 21 - 18
libs/src/LSPlugin.core.ts

@@ -19,7 +19,8 @@ import {
   cleanInjectedScripts,
   safeSnakeCase,
   injectTheme,
-  cleanInjectedUI, PluginLogger,
+  cleanInjectedUI,
+  PluginLogger,
 } from './helpers'
 import * as pluginHelpers from './helpers'
 import Debug from 'debug'
@@ -910,27 +911,27 @@ class PluginLocal extends EventEmitter<'loaded'
     }
 
     try {
-      this._status = PluginLocalLoadStatus.UNLOADING
-
       const eventBeforeUnload = { unregister }
 
-      // sync call
-      try {
-        await this._caller?.callUserModel(
-          AWAIT_LSPMSGFn(LSPMSG_BEFORE_UNLOAD),
-          eventBeforeUnload
-        )
-        this.emit('beforeunload', eventBeforeUnload)
-      } catch (e) {
-        console.error('[beforeunload Error]', e)
-      }
+      if (this.loaded) {
+        this._status = PluginLocalLoadStatus.UNLOADING
 
-      await this.dispose()
+        try {
+          await this._caller?.callUserModel(
+            AWAIT_LSPMSGFn(LSPMSG_BEFORE_UNLOAD),
+            eventBeforeUnload
+          )
+          this.emit('beforeunload', eventBeforeUnload)
+        } catch (e) {
+          this.logger.error('[beforeunload Error]', e)
+        }
+
+        await this.dispose()
+      }
 
       this.emit('unloaded')
     } catch (e) {
-      debug('[plugin unload Error]', e)
-      return false
+      this.logger.error('[unload Error]', e)
     } finally {
       this._status = PluginLocalLoadStatus.UNLOADED
     }
@@ -1084,7 +1085,8 @@ class PluginLocal extends EventEmitter<'loaded'
  * Host plugin core
  */
 class LSPluginCore
-  extends EventEmitter<'beforeenable'
+  extends EventEmitter<
+    | 'beforeenable'
     | 'enabled'
     | 'beforedisable'
     | 'disabled'
@@ -1098,7 +1100,8 @@ class LSPluginCore
     | 'settings-changed'
     | 'unlink-plugin'
     | 'beforereload'
-    | 'reloaded'>
+    | 'reloaded'
+  >
   implements ILSPluginThemeManager {
   private _isRegistering = false
   private _readyIndicator?: DeferredActor

+ 1 - 1
libs/src/LSPlugin.ts

@@ -381,7 +381,7 @@ export interface IAppProxy {
   ) => Promise<void>
 
   /**
-   * Call external plugin command provided by models or registerd commands
+   * Call external plugin command provided by models or registered commands
    * @added 0.0.13
    * @param type `xx-plugin-id.commands.xx-key`, `xx-plugin-id.models.xx-key`
    * @param args

+ 2 - 2
package.json

@@ -6,7 +6,7 @@
     "devDependencies": {
         "@axe-core/playwright": "=4.4.4",
         "@capacitor/cli": "^4.0.0",
-        "@playwright/test": "=1.25.2",
+        "@playwright/test": "=1.31.0",
         "@tailwindcss/aspect-ratio": "0.4.2",
         "@tailwindcss/forms": "0.5.3",
         "@tailwindcss/line-clamp": "0.4.2",
@@ -20,7 +20,7 @@
         "gulp-clean-css": "^4.3.0",
         "ip": "1.1.8",
         "npm-run-all": "^4.1.5",
-        "playwright": "=1.25.2",
+        "playwright": "=1.31.0",
         "postcss": "8.4.17",
         "postcss-cli": "10.0.0",
         "postcss-import": "15.0.0",

+ 13 - 6
resources/css/common.css

@@ -95,9 +95,9 @@ html[data-theme='dark'] {
   --ls-highlight-color-blue: var(--color-blue-900);
   --ls-highlight-color-purple: var(--color-purple-900);
   --ls-highlight-color-pink: var(--color-pink-900);
-  --ls-error-text-color: var(--color-red-100);
+  --ls-error-text-color: var(--color-red-400);
   --ls-error-background-color: var(--color-red-900);
-  --ls-warning-text-color: var(--color-yellow-100);
+  --ls-warning-text-color: var(--color-yellow-400);
   --ls-warning-background-color: var(--color-yellow-900);
   --ls-success-text-color: var(--color-green-100);
   --ls-success-background-color: var(--color-green-900);
@@ -173,9 +173,9 @@ html[data-theme='light'] {
   --ls-highlight-color-blue: var(--color-blue-100);
   --ls-highlight-color-purple: var(--color-purple-100);
   --ls-highlight-color-pink: var(--color-pink-100);
-  --ls-error-text-color: var(--color-red-800);
+  --ls-error-text-color: var(--color-red-600);
   --ls-error-background-color: var(--color-red-100);
-  --ls-warning-text-color: var(--color-yellow-800);
+  --ls-warning-text-color: var(--color-yellow-700);
   --ls-warning-background-color: var(--color-yellow-100);
   --ls-success-text-color: var(--color-green-800);
   --ls-success-background-color: var(--color-green-100);
@@ -203,7 +203,7 @@ html {
 }
 
 body {
-  color: #24292e;
+  color: var(--ls-primary-text-color);
   line-height: 1.5;
   background-color: transparent;
   min-height: 100%;
@@ -914,4 +914,11 @@ html.is-mobile {
   #journals .journal-item:first-child {
     margin-top: 5px;
   }
-}
+}
+
+@layer base {
+    .ls-grid-cols {
+        @apply grid grid-flow-col auto-cols-max;
+        place-items: center;
+    }
+}

+ 36 - 0
resources/css/show-hint.css

@@ -0,0 +1,36 @@
+.CodeMirror-hints {
+  position: absolute;
+  z-index: 10;
+  overflow: hidden;
+  list-style: none;
+
+  margin: 0;
+  padding: 2px;
+
+  -webkit-box-shadow: 2px 3px 5px rgba(0,0,0,.2);
+  -moz-box-shadow: 2px 3px 5px rgba(0,0,0,.2);
+  box-shadow: 2px 3px 5px rgba(0,0,0,.2);
+  border-radius: 3px;
+  border: 1px solid silver;
+
+  background: white;
+  font-size: 90%;
+  font-family: monospace;
+
+  max-height: 20em;
+  overflow-y: auto;
+}
+
+.CodeMirror-hint {
+  margin: 0;
+  padding: 0 4px;
+  border-radius: 2px;
+  white-space: pre;
+  color: black;
+  cursor: pointer;
+}
+
+li.CodeMirror-hint-active {
+  background: #08f;
+  color: white;
+}

+ 3 - 3
resources/forge.config.js

@@ -6,9 +6,9 @@ module.exports = {
     icon: './icons/logseq_big_sur.icns',
     protocols: [
       {
-        "protocol":"logseq",
-        "name":"logseq",
-        "schemes":"logseq"
+        "protocol": "logseq",
+        "name": "logseq",
+        "schemes": "logseq"
       }
     ],
     osxSign: {

ファイルの差分が大きいため隠しています
+ 0 - 0
resources/js/lsplugin.core.js


+ 3 - 2
resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "Logseq",
   "productName": "Logseq",
-  "version": "0.8.16",
+  "version": "0.8.18",
   "main": "electron.js",
   "author": "Logseq",
   "license": "AGPL-3.0",
@@ -37,10 +37,11 @@
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
     "posthog-js": "1.10.2",
-    "@logseq/rsapi": "0.0.65",
+    "@logseq/rsapi": "0.0.67",
     "electron-deeplink": "1.0.10",
     "abort-controller": "3.0.0",
     "fastify": "latest",
+    "@fastify/cors": "8.2.0",
     "command-exists": "1.2.9"
   },
   "devDependencies": {

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

@@ -16,7 +16,7 @@
   (doseq [cmd ["clojure -M:clj-kondo --parallel --lint src --cache false"
                "bb lint:carve"
                "bb lint:large-vars"
-               "bb lang:invalid-translations"
+               "bb lang:validate-translations"
                "bb lint:ns-docstrings"]]
     (println cmd)
     (shell cmd)))

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

@@ -194,7 +194,7 @@
     \"dir\": \"/Users/me/Documents/untitled folder 31\"}
   ```
 
-  * you alse need to open logseq-app(or yarn electron-watch),
+  * you also need to open logseq-app(or yarn electron-watch),
     and open <dir> and start file-sync"
   [& _args]
   (setup-vars)

+ 66 - 5
scripts/src/logseq/tasks/lang.clj

@@ -1,9 +1,11 @@
 (ns logseq.tasks.lang
   "Tasks related to language translations"
   (:require [clojure.set :as set]
+            [clojure.string :as string]
             [frontend.dicts :as dicts]
             [frontend.modules.shortcut.dicts :as shortcut-dicts]
-            [logseq.tasks.util :as task-util]))
+            [logseq.tasks.util :as task-util]
+            [babashka.process :refer [shell]]))
 
 (defn- get-dicts
   []
@@ -69,8 +71,11 @@
            (sort-by (juxt :file :translation-key))
            task-util/print-table))))
 
-(defn invalid-translations
-  "Lists translation that don't exist in English"
+(defn- validate-non-default-languages
+  "This validation finds any translation keys that don't exist in the default
+  language English. Logseq needs to work out of the box with its default
+  language. This catches mistakes where another language has accidentally typoed
+  keys or added ones without updating :en"
   []
   (let [dicts (get-all-dicts)
         ;; For now defined as :en but clj-kondo analysis could be more thorough
@@ -83,12 +88,68 @@
                         (set/difference (set (keys get-dicts))
                                         valid-keys)))))]
     (if (empty? invalid-dicts)
-      (println "All translations have valid keys!")
+      (println "All non-default translations have valid keys!")
       (do
-        (println "Invalid translation keys found:")
+        (println "\nThese translation keys are invalid because they don't exist in English:")
         (task-util/print-table invalid-dicts)
         (System/exit 1)))))
 
+;; Command to check for manual entries:
+;; grep -E -oh  '\(t [^ ):]+' -r src/main
+(def manual-ui-dicts
+  "Manual list of ui translations because they are dynamic i.e. keyword isn't
+  first arg. Only map values are used in linter as keys are for easily scanning
+  grep result."
+
+  {"(t (shortcut-helper/decorate-namespace" [] ;; shortcuts related so can ignore
+   "(t (keyword" [:color/yellow :color/red :color/pink :color/green :color/blue
+                  :color/purple :color/gray]
+   ;; from 3 files
+   "(t (if" [:asset/show-in-folder :asset/open-in-browser
+             :search-item/whiteboard :search-item/page
+             :page/make-private :page/make-public]
+   "(t (name" [] ;; shortcuts related
+   "(t (dh/decorate-namespace" [] ;; shortcuts related
+   "(t prompt-key" [:select/default-prompt :select/default-select-multiple :select.graph/prompt]
+   ;; All args to ui/make-confirm-modal are not keywords
+   "(t title" []
+   "(t subtitle" [:asset/physical-delete]})
+
+(defn- validate-ui-translations-are-used
+  "This validation checks to see that translations done by (t ...) are equal to
+  the ones defined for the default :en lang. This catches translations that have
+  been added in UI but don't have an entry or translations no longer used in the UI"
+  []
+  (let [actual-dicts (->> (shell {:out :string}
+                                 ;; This currently assumes all ui translations
+                                 ;; use (t and src/main. This can easily be
+                                 ;; tweaked as needed
+                                 "grep -E -oh '\\(t :[^ )]+' -r src/main")
+                          :out
+                          string/split-lines
+                          (map #(keyword (subs % 4)))
+                          (concat (mapcat val manual-ui-dicts))
+                          set)
+        expected-dicts (set (keys (:en (get-dicts))))
+        actual-only (set/difference actual-dicts expected-dicts)
+        expected-only (set/difference expected-dicts actual-dicts)]
+    (if (and (empty? actual-only) (empty? expected-only))
+      (println "All defined :en translation keys match the ones that are used!")
+      (do
+        (when (seq actual-only)
+          (println "\nThese translation keys are invalid because they are used in the UI but not defined:")
+          (task-util/print-table (map #(hash-map :invalid-key %) actual-only)))
+        (when (seq expected-only)
+          (println "\nThese translation keys are invalid because they are not used in the UI:")
+          (task-util/print-table (map #(hash-map :invalid-key %) expected-only)))
+        (System/exit 1)))))
+
+(defn validate-translations
+  "Runs multiple translation validations that fail fast if one of them is invalid"
+  []
+  (validate-non-default-languages)
+  (validate-ui-translations-are-used))
+
 (defn list-duplicates
   "Lists translations that are the same as the one in English"
   [& args]

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

@@ -5,6 +5,8 @@
             [frontend.schema.handler.plugin-config :as plugin-config-schema]
             [frontend.schema.handler.global-config :as global-config-schema]
             [frontend.schema.handler.repo-config :as repo-config-schema]
+            [logseq.graph-parser.schema.mldoc :as mldoc-schema]
+            [babashka.fs :as fs]
             [clojure.pprint :as pprint]
             [clojure.edn :as edn]))
 
@@ -43,3 +45,28 @@
   "Validate a global config.edn"
   [file]
   (validate-file-with-schema file repo-config-schema/Config-edn))
+
+(defn validate-ast
+  "Validate mldoc ast(s) in a file or as an EDN arg"
+  [file-or-edn]
+  (let [edn (edn/read-string
+             (if (fs/exists? file-or-edn) (slurp file-or-edn) file-or-edn))]
+    (if (and (sequential? edn) (:ast (first edn)))
+      ;; Validate multiple asts in the format [{:file "" :ast []} ...]
+      ;; Produced by https://github.com/logseq/nbb-logseq/tree/main/examples/from-js#graph_astmjs
+      (do
+        (println "Validating" (count edn) "files...")
+        (if-let [errors-by-file (seq (keep
+                                      #(when-let [errors (m/explain mldoc-schema/block-ast-with-pos-coll-schema (:ast %))]
+                                         {:file (:file %)
+                                          :errors errors})
+                                      edn))]
+          (do
+            (println "Found errors:")
+            (pprint/pprint errors-by-file))
+          (println "All files valid!")))
+      (if-let [errors (m/explain mldoc-schema/block-ast-with-pos-coll-schema edn)]
+        (do
+          (println "Found errors:")
+          (pprint/pprint errors))
+        (println "Valid!")))))

+ 6 - 7
src/electron/electron/context_menu.cljs

@@ -1,10 +1,9 @@
 (ns electron.context-menu
-  (:require [clojure.string :as string]
-            [electron.utils :as utils]
-            ["electron" :refer [Menu MenuItem shell] :as electron]
+  (:require [electron.utils :as utils]
+            ["electron" :refer [Menu MenuItem shell nativeImage clipboard] :as electron]
             ["electron-dl" :refer [download]]))
 
-;; context menu is registerd in window/setup-window-listeners!
+;; context menu is registered in window/setup-window-listeners!
 (defn setup-context-menu!
   [^js win]
   (let [web-contents (.-webContents win)
@@ -16,7 +15,7 @@
                 edit-flags (.-editFlags params)
                 editable? (.-isEditable params)
                 selection-text (.-selectionText params)
-                has-text? (not (string/blank? (string/trim selection-text)))
+                has-text? (seq selection-text)
                 link-url (not-empty (.-linkURL params))
                 media-type (.-mediaType params)]
 
@@ -46,7 +45,6 @@
                                            (.. shell (openExternal (.toString url))))}))
               (. menu append (MenuItem. #js {:type "separator"})))
 
-
             (when editable?
               (when has-text?
                 (. menu append
@@ -88,7 +86,8 @@
 
               (. menu append
                  (MenuItem. #js {:label "Copy Image"
-                                 :click #(. web-contents copyImageAt (.-x params) (.-y params))})))
+                                 :click (fn []
+                                          (. clipboard writeImage (. nativeImage createFromPath (subs (.-srcURL params) 7))))})))
 
             (when (not-empty (.-items menu))
               (. menu popup))))]

+ 5 - 5
src/electron/electron/core.cljs

@@ -248,7 +248,7 @@
 
 (defn- setup-deeplink! []
   ;; Works for Deeplink v1.0.9
-  ;; :mainWindow is only used for handeling window restoring on second-instance,
+  ;; :mainWindow is only used for handling window restoring on second-instance,
   ;; But we already handle window restoring without deeplink.
   ;; https://github.com/glawson/electron-deeplink/blob/73d58edcde3d0e80b1819cd68a0c6e837a9c9258/src/index.ts#L150-L155
   (-> (Deeplink. #js
@@ -290,7 +290,7 @@
                (win/switch-to-window! window))))
 
       (.on app "window-all-closed" (fn []
-                                     (logger/debug "window-all-closed" "Quiting...")
+                                     (logger/debug "window-all-closed" "Quitting...")
                                      (try
                                        (fs-watcher/close-watcher!)
                                        (search/close!)
@@ -300,7 +300,7 @@
       (.on app "ready"
            (fn []
              (let [t0 (setup-interceptor! app)
-                   ^js win (win/create-main-window)
+                   ^js win (win/create-main-window!)
                    _ (reset! *win win)]
                (logger/info (str "Logseq App(" (.getVersion app) ") Starting... "))
 
@@ -342,7 +342,7 @@
                                       (let [_ (async/<! state/persistent-dbs-chan)]
                                         (if (or @win/*quitting? (not mac?))
                                           ;; MacOS: only cmd+q quitting will trigger actual closing
-                                          ;; otherwise, it's just hiding - don't do any actuall closing in that case
+                                          ;; otherwise, it's just hiding - don't do any actual closing in that case
                                           ;; except saving transit
                                           (when-let [win @*win]
                                             (when-let [dir (state/get-window-graph-path win)]
@@ -351,7 +351,7 @@
                                             (win/destroy-window! win)
                                             ;; FIXME: what happens when closing main window on Windows?
                                             (reset! *win nil))
-                                          ;; Just hiding - don't do any actuall closing operation
+                                          ;; Just hiding - don't do any actual closing operation
                                           (do (.preventDefault ^js/Event e)
                                               (if (and mac? (.isFullScreen win))
                                                 (do (.once win "leave-full-screen" #(.hide win))

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

@@ -98,7 +98,9 @@
 
 (defn commit!
   [message]
-  (run-git! #js ["commit" "-m" message]))
+  (p/do!
+   (run-git! #js ["config" "core.quotepath" "false"])
+   (run-git! #js ["commit" "-m" message])))
 
 (defn add-all-and-commit!
   ([]
@@ -120,6 +122,10 @@
                      (utils/send-to-renderer "notification" {:type "error"
                                                              :payload (str error "\nIf you don't want to see those errors or don't need git, you can disable the \"Git auto commit\" feature on Settings > Version control.")})))))))))
 
+(defn short-status!
+  []
+  (run-git! #js ["status" "--porcelain"]))
+
 (defonce quotes-regex #"\"[^\"]+\"")
 (defn wrapped-by-quotes?
   [v]

+ 5 - 2
src/electron/electron/handler.cljs

@@ -442,7 +442,7 @@
         (do (cfgs/set-item! k v)
             (state/set-state! [:config k] v))
         (cfgs/get-item k))
-     config)))
+      config)))
 
 (defmethod handle :getDirname [_]
   js/__dirname)
@@ -506,6 +506,9 @@
 (defmethod handle :gitCommitAll [_ [_ message]]
   (git/add-all-and-commit! message))
 
+(defmethod handle :gitStatus [_ [_]]
+  (git/short-status!))
+
 (defmethod handle :installMarketPlugin [_ [_ mft]]
   (plugin/install-or-update! mft))
 
@@ -602,7 +605,7 @@
 (defn open-new-window!
   "Persist db first before calling! Or may break db persistency"
   []
-  (let [win (win/create-main-window)]
+  (let [win (win/create-main-window!)]
     (win/on-close-actions! win close-watcher-when-orphaned!)
     (win/setup-window-listeners! win)
     win))

+ 19 - 19
src/electron/electron/search.cljs

@@ -134,18 +134,18 @@
 
 (defn open-db!
   [db-name]
-    (let [[db-sanitized-name db-full-path] (get-db-full-path db-name)]
-      (try (let [db (sqlite3 db-full-path nil)]
-             (create-blocks-table! db)
-             (create-blocks-fts-table! db)
-             (create-pages-table! db)
-             (create-pages-fts-table! db)
-             (add-blocks-fts-triggers! db)
-             (add-pages-fts-triggers! db)
-             (swap! databases assoc db-sanitized-name db))
-           (catch :default e
-             (logger/error (str e ": " db-name))
-             (fs/unlinkSync db-full-path)))))
+  (let [[db-sanitized-name db-full-path] (get-db-full-path db-name)]
+    (try (let [db (sqlite3 db-full-path nil)]
+           (create-blocks-table! db)
+           (create-blocks-fts-table! db)
+           (create-pages-table! db)
+           (create-pages-fts-table! db)
+           (add-blocks-fts-triggers! db)
+           (add-pages-fts-triggers! db)
+           (swap! databases assoc db-sanitized-name db))
+         (catch :default e
+           (logger/error (str e ": " db-name))
+           (fs/unlinkSync db-full-path)))))
 
 (defn open-dbs!
   []
@@ -246,7 +246,7 @@
   (medley/distinct-by f (seq col)))
 
 (defn search-blocks
-  ":page - the page to specificly search on"
+  ":page - the page to specifically search on"
   [repo q {:keys [limit page]}]
   (when-let [database (get-db repo)]
     (when-not (string/blank? q)
@@ -263,9 +263,9 @@
                                " content like ? limit ?")
             matched-result (->>
                             (map
-                              (fn [match-input]
-                                (search-blocks-aux database match-sql match-input page limit))
-                              match-inputs)
+                             (fn [match-input]
+                               (search-blocks-aux database match-sql match-input page limit))
+                             match-inputs)
                             (apply concat))]
         (->>
          (concat matched-result
@@ -328,9 +328,9 @@
                                " content like ? limit ?")
             matched-result (->>
                             (map
-                              (fn [match-input]
-                                (search-pages-aux database match-sql match-input limit))
-                              match-inputs)
+                             (fn [match-input]
+                               (search-pages-aux database match-sql match-input limit))
+                             match-inputs)
                             (apply concat))]
         (->>
          (concat matched-result

+ 10 - 4
src/electron/electron/server.cljs

@@ -1,5 +1,6 @@
 (ns electron.server
   (:require ["fastify" :as Fastify]
+            ["@fastify/cors" :as FastifyCORS]
             ["electron" :refer [ipcMain]]
             ["fs-extra" :as fs-extra]
             ["path" :as path]
@@ -9,7 +10,8 @@
             [electron.utils :as utils]
             [camel-snake-kebab.core :as csk]
             [electron.logger :as logger]
-            [electron.configs :as cfgs]))
+            [electron.configs :as cfgs]
+            [electron.window :as window]))
 
 (defonce ^:private *win (atom nil))
 (defonce ^:private *server (atom nil))
@@ -36,7 +38,9 @@
 
 (defn load-state-to-renderer!
   ([] (load-state-to-renderer! @*state))
-  ([s] (utils/send-to-renderer @*win :syncAPIServerState s)))
+  ([s]
+   (doseq [^js w (window/get-all-windows)]
+     (utils/send-to-renderer w :syncAPIServerState s))))
 
 (defn set-config!
   [config]
@@ -113,7 +117,7 @@
 
 (defn close!
   []
-  (when (and @*server (= :running (:status @*state)))
+  (when (and @*server (contains? #{:running :error} (:status @*state)))
     (logger/debug "[server] closing ...")
     (set-status! :closing)
     (-> (.close @*server)
@@ -127,9 +131,11 @@
   []
   (-> (p/let [_     (close!)
               _     (set-status! :starting)
-              ^js s (Fastify. #js {:logger                true
+              ^js s (Fastify. #js {:logger                (not utils/win32?)
                                    :requestTimeout        (* 1000 42)
                                    :forceCloseConnections true})
+              ;; middlewares
+              _     (.register s FastifyCORS #js {:origin "*"})
               ;; hooks & routes
               _     (doto s
                       (.addHook "preHandler" api-pre-handler!)

+ 2 - 2
src/electron/electron/url.cljs

@@ -59,12 +59,12 @@
       (graph-identifier-error-handler graph-identifier))))
 
 (defn- x-callback-url-handler
-  "win - a window used for fallback (main window is prefered)"
+  "win - a window used for fallback (main window is preferred)"
   [^js win ^js/URL parsed-url]
   (let [action (.-pathname parsed-url)]
     (cond
       ;; url:     (string) Page url
-      ;; title:   (stirng) Page title
+      ;; title:   (string) Page title
       ;; content: (string) Highlighted text
       ;; page:    (string) Page name to insert to, use "TODAY" to insert to today page
       ;; append:  (bool)   Append to the end of the page, default to false(current editing position)

+ 10 - 10
src/electron/electron/utils.cljs

@@ -35,12 +35,19 @@
   ([url options]
    (_fetch url (bean/->js (merge options {:agent @*fetchAgent})))))
 
+(defn fix-win-path!
+  [path]
+  (when path
+    (if win32?
+      (string/replace path "\\" "/")
+      path)))
+
 (defn get-ls-dotdir-root
   []
   (let [lg-dir (path/join (.getPath app "home") ".logseq")]
-    (if-not (fs/existsSync lg-dir)
-      (do (fs/mkdirSync lg-dir) lg-dir)
-      lg-dir)))
+    (when-not (fs/existsSync lg-dir)
+      (fs/mkdirSync lg-dir))
+    (fix-win-path! lg-dir)))
 
 (defn get-ls-default-plugins
   []
@@ -214,13 +221,6 @@
   (let [ext (string/lower-case (path/extname path))]
     (contains? #{".md" ".markdown" ".org" ".js" ".edn" ".css"} ext)))
 
-(defn fix-win-path!
-  [path]
-  (when path
-    (if win32?
-      (string/replace path "\\" "/")
-      path)))
-
 (defn read-file
   [path]
   (try

+ 72 - 37
src/electron/electron/window.cljs

@@ -8,6 +8,7 @@
             ["path" :as path]
             ["url" :as URL]
             [electron.state :as state]
+            [cljs-bean.core :as bean]
             [clojure.core.async :as async]
             [clojure.string :as string]))
 
@@ -18,44 +19,51 @@
                          (str "file://" (path/join js/__dirname "index.html"))
                          (str "file://" (path/join js/__dirname "electron.html"))))
 
-(defn create-main-window
+(defn create-main-window!
   ([]
-   (create-main-window MAIN_WINDOW_ENTRY))
+   (create-main-window! MAIN_WINDOW_ENTRY nil))
   ([url]
+   (create-main-window! url nil))
+  ([url opts]
    (let [win-state (windowStateKeeper (clj->js {:defaultWidth 980 :defaultHeight 700}))
-         win-opts (cond->
-                    {:width                (.-width win-state)
-                     :height               (.-height win-state)
-                     :frame                true
-                     :titleBarStyle        "hiddenInset"
-                     :trafficLightPosition {:x 16 :y 16}
-                     :autoHideMenuBar      (not mac?)
-                     :webPreferences
-                     {:plugins                 true ; pdf
-                      :nodeIntegration         false
-                      :nodeIntegrationInWorker false
-                      :sandbox                 false
-                      :webSecurity             (not dev?)
-                      :contextIsolation        true
-                      :spellcheck              ((fnil identity true) (cfgs/get-item :spell-check))
-                      ;; Remove OverlayScrollbars and transition `.scrollbar-spacing`
-                      ;; to use `scollbar-gutter` after the feature is implemented in browsers.
-                      :enableBlinkFeatures     'OverlayScrollbars'
-                      :preload                 (path/join js/__dirname "js/preload.js")}}
-                    linux?
-                    (assoc :icon (path/join js/__dirname "icons/logseq.png")))
-         win (BrowserWindow. (clj->js win-opts))]
+         win-opts  (cond->
+                     {:width                (.-width win-state)
+                      :height               (.-height win-state)
+                      :frame                true
+                      :titleBarStyle        "hiddenInset"
+                      :trafficLightPosition {:x 16 :y 16}
+                      :autoHideMenuBar      (not mac?)
+                      :webPreferences
+                      {:plugins                 true        ; pdf
+                       :nodeIntegration         false
+                       :nodeIntegrationInWorker false
+                       :nativeWindowOpen        true
+                       :sandbox                 false
+                       :webSecurity             (not dev?)
+                       :contextIsolation        true
+                       :spellcheck              ((fnil identity true) (cfgs/get-item :spell-check))
+                       ;; Remove OverlayScrollbars and transition `.scrollbar-spacing`
+                       ;; to use `scollbar-gutter` after the feature is implemented in browsers.
+                       :enableBlinkFeatures     'OverlayScrollbars'
+                       :preload                 (path/join js/__dirname "js/preload.js")}}
+
+                     (seq opts)
+                     (merge opts)
+
+                     linux?
+                     (assoc :icon (path/join js/__dirname "icons/logseq.png")))
+         win       (BrowserWindow. (clj->js win-opts))]
      (.manage win-state win)
      (.onBeforeSendHeaders (.. session -defaultSession -webRequest)
                            (clj->js {:urls (array "*://*.youtube.com/*")})
                            (fn [^js details callback]
-                             (let [url (.-url details)
-                                   urlObj (js/URL. url)
-                                   origin (.-origin urlObj)
+                             (let [url            (.-url details)
+                                   urlObj         (js/URL. url)
+                                   origin         (.-origin urlObj)
                                    requestHeaders (.-requestHeaders details)]
                                (if (and
-                                     (.hasOwnProperty requestHeaders "referer")
-                                     (not-empty (.-referer requestHeaders)))
+                                    (.hasOwnProperty requestHeaders "referer")
+                                    (not-empty (.-referer requestHeaders)))
                                  (callback #js {:cancel         false
                                                 :requestHeaders requestHeaders})
                                  (do
@@ -121,8 +129,8 @@
   [^js win]
   (when win
     (let [web-contents (. win -webContents)
-          new-win-handler
-          (fn [e url]
+          open-external!
+          (fn [url]
             (let [url (if (string/starts-with? url "file:")
                         (utils/safe-decode-uri-component url) url)
                   url (if-not win32? (string/replace url "file://" "") url)]
@@ -132,8 +140,7 @@
                           (.join path (. app getAppPath) %))
                         ["index.html" "electron.html"])
                 (logger/info "pass-window" url)
-                (open-default-app! url open)))
-            (.preventDefault e))
+                (open-default-app! url open))))
 
           will-navigate-handler
           (fn [e url]
@@ -141,13 +148,42 @@
             (open-default-app! url open))
 
           context-menu-handler
-          (context-menu/setup-context-menu! win)]
+          (context-menu/setup-context-menu! win)
+
+          window-open-handler
+          (fn [^js details]
+            (let [url         (.-url details)
+                  fullscreen? (.isFullScreen win)
+                  features    (string/split (.-features details) ",")
+                  features    (when (seq features)
+                                (reduce (fn [a b]
+                                          (let [[k v] (string/split b "=")]
+                                            (if (string? v)
+                                              (assoc a (keyword k) (parse-long (string/trim v)))
+                                              a))) {} features))]
+              (-> (if (= url "about:blank")
+                    (merge {:action "allow"
+                            :overrideBrowserWindowOptions
+                            {:frame                true
+                             :titleBarStyle        "default"
+                             :trafficLightPosition {:x 16 :y 16}
+                             :autoHideMenuBar      (not mac?)
+                             :fullscreenable       (not fullscreen?)
+                             :webPreferences
+                             {:plugins          true
+                              :nodeIntegration  false
+                              :webSecurity      (not dev?)
+                              :preload          (path/join js/__dirname "js/preload.js")
+                              :nativeWindowOpen true}}}
+                           features)
+                    (do (open-external! url) {:action "deny"}))
+                  (bean/->js))))]
 
       (doto web-contents
-        (.on "new-window" new-win-handler)
         (.on "will-navigate" will-navigate-handler)
         (.on "did-start-navigation" #(.send web-contents "persist-zoom-level" (.getZoomLevel web-contents)))
-        (.on "page-title-updated" #(.send web-contents "restore-zoom-level")))
+        (.on "page-title-updated" #(.send web-contents "restore-zoom-level"))
+        (.setWindowOpenHandler window-open-handler))
 
       (doto win
         (.on "enter-full-screen" #(.send web-contents "full-screen" "enter"))
@@ -157,7 +193,6 @@
       (fn []
         (doto web-contents
           (.off "context-menu" context-menu-handler)
-          (.off "new-window" new-win-handler)
           (.off "will-navigate" will-navigate-handler))
 
         (.off win "enter-full-screen")

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

@@ -268,7 +268,8 @@
 
     ;; advanced
 
-    [["Query" [[:editor/input "{{query }}" {:backward-pos 2}]] query-doc]
+    [["Query" [[:editor/input "{{query }}" {:backward-pos 2}]
+               [:editor/exit]] query-doc]
      ["Zotero" (zotero-steps) "Import Zotero journal article"]
      ["Query table function" [[:editor/input "{{function }}" {:backward-pos 2}]] "Create a query table function"]
      ["Calculator" [[:editor/input "```calc\n\n```" {:backward-pos 4}]
@@ -667,6 +668,9 @@
   (when-let [input-file (gdom/getElement "upload-file")]
     (.click input-file)))
 
+(defmethod handle-step :editor/exit [[_]]
+  (state/clear-edit!))
+
 (defmethod handle-step :default [[type & _args]]
   (prn "No handler for step: " type))
 

+ 326 - 274
src/main/frontend/components/block.cljs

@@ -17,6 +17,7 @@
             [frontend.components.macro :as macro]
             [frontend.components.plugins :as plugins]
             [frontend.components.query-table :as query-table]
+            [frontend.components.query.builder :as query-builder-component]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
@@ -25,7 +26,6 @@
             [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as model]
             [frontend.db.query-dsl :as query-dsl]
-            [frontend.db.react :as react]
             [frontend.db.utils :as db-utils]
             [frontend.extensions.highlight :as highlight]
             [frontend.extensions.latex :as latex]
@@ -50,6 +50,7 @@
             [frontend.handler.route :as route-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.whiteboard :as whiteboard-handler]
+            [frontend.handler.export.common :as export-common-handler]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.outliner.tree :as tree]
             [frontend.search :as search]
@@ -58,6 +59,7 @@
             [frontend.template :as template]
             [frontend.ui :as ui]
             [frontend.util :as util]
+            [frontend.extensions.pdf.utils :as pdf-utils]
             [frontend.util.clock :as clock]
             [frontend.util.drawer :as drawer]
             [frontend.util.property :as property]
@@ -78,7 +80,8 @@
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
-            [shadow.loader :as loader]))
+            [shadow.loader :as loader]
+            [datascript.impl.entity :as e]))
 
 (defn safe-read-string
   ([s]
@@ -159,7 +162,7 @@
     (when current-file
       (let [parts (string/split current-file #"/")
             parts-2 (string/split path #"/")
-            current-dir (string/join "/" (drop-last 1 parts))]
+            current-dir (util/string-join-path (drop-last 1 parts))]
         (cond
           (if util/win32? (utils/win32 path) (util/starts-with? path "/"))
           path
@@ -184,7 +187,7 @@
                                    parts
                                    (rest col)))))
                 parts (remove #(string/blank? %) parts)]
-            (string/join "/" (reverse parts))))))))
+            (util/string-join-path (reverse parts))))))))
 
 (rum/defcs asset-loader
   < rum/reactive
@@ -275,7 +278,7 @@
   (let [size (get state ::size)]
     (ui/resize-provider
      (ui/resize-consumer
-      (if-not (mobile-util/native-ios?)
+      (if (not (mobile-util/native-platform?))
         (cond->
          {:className "resize image-resize"
           :onSizeChanged (fn [value]
@@ -454,40 +457,8 @@
                       (get-file-absolute-path config href)))]
          (resizable-image config title href metadata full_text false))))))
 
-(defn repetition-to-string
-  [[[kind] [duration] n]]
-  (let [kind (case kind
-               "Dotted" "."
-               "Plus" "+"
-               "DoublePlus" "++")]
-    (str kind n (string/lower-case (str (first duration))))))
-
-(defn timestamp-to-string
-  [{:keys [_active date time repetition wday active]}]
-  (let [{:keys [year month day]} date
-        {:keys [hour min]} time
-        [open close] (if active ["<" ">"] ["[" "]"])
-        repetition (if repetition
-                     (str " " (repetition-to-string repetition))
-                     "")
-        hour (when hour (util/zero-pad hour))
-        min  (when min (util/zero-pad min))
-        time (cond
-               (and hour min)
-               (util/format " %s:%s" hour min)
-               hour
-               (util/format " %s" hour)
-               :else
-               "")]
-    (util/format "%s%s-%s-%s %s%s%s%s"
-                 open
-                 (str year)
-                 (util/zero-pad month)
-                 (util/zero-pad day)
-                 wday
-                 time
-                 repetition
-                 close)))
+
+(def timestamp-to-string export-common-handler/timestamp-to-string)
 
 (defn timestamp [{:keys [active _date _time _repetition _wday] :as t} kind]
   (let [prefix (case kind
@@ -540,7 +511,7 @@
          (:db/id page-entity)
          :page))
 
-      (whiteboard-handler/inside-portal? (.-target e))
+      (and (util/meta-key? e) (whiteboard-handler/inside-portal? (.-target e)))
       (whiteboard-handler/add-new-block-portal-shape!
        page-name
        (whiteboard-handler/closest-shape (.-target e)))
@@ -558,14 +529,16 @@
              (state/get-left-sidebar-open?))
     (ui-handler/close-left-sidebar!)))
 
-(rum/defc page-inner
+(rum/defcs page-inner <
+  (rum/local false ::mouse-down?)
   "The inner div of page reference component
 
    page-name-in-block is the overridable name of the page (legacy)
 
    All page-names are sanitized except page-name-in-block"
-  [config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?]
-  (let [tag? (:tag? config)
+  [state config page-name-in-block page-name redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?]
+  (let [*mouse-down? (::mouse-down? state)
+        tag? (:tag? config)
         config (assoc config :whiteboard-page? whiteboard-page?)
         untitled? (model/untitled-page? page-name)]
     [:a
@@ -575,7 +548,13 @@
                (str " page-property-key block-property")
                untitled? (str " opacity-50"))
       :data-ref page-name
-      :on-mouse-down (fn [e] (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?))
+      :draggable true
+      :on-drag-start (fn [e] (editor-handler/block->data-transfer! page-name e))
+      :on-mouse-down (fn [_e] (reset! *mouse-down? true))
+      :on-mouse-up (fn [e]
+                     (when @*mouse-down?
+                       (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?)
+                       (reset! *mouse-down? false)))
       :on-key-up (fn [e] (when (and e (= (.-key e) "Enter"))
                            (open-page-ref e page-name redirect-page-name page-name-in-block contents-page? whiteboard-page?)))}
 
@@ -600,11 +579,18 @@
                s (cond untitled?
                        (t :untitled)
 
+                       ;; The page-name-in-block generated by the auto-complete is not page-name-sanitized
+                       (pdf-utils/hls-file? page-name)
+                       (pdf-utils/fix-local-asset-pagename page-name)
+
                        (not= (util/safe-page-name-sanity-lc original-name) page-name-in-block)
-                       page-name-in-block ;; page-name-in-block might be overrided (legacy))
+                       page-name-in-block ;; page-name-in-block might be overridden (legacy))
+
+                       original-name
+                       (util/trim-safe original-name)
 
                        :else
-                       (pdf-assets/human-page-name original-name))
+                       (util/trim-safe page-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))]
            (if tag? (str "#" s) s))))]))
@@ -909,7 +895,7 @@
                      (:db/id block)
                      :block-ref)
 
-                    (whiteboard-handler/inside-portal? (.-target e))
+                    (and (util/meta-key? e) (whiteboard-handler/inside-portal? (.-target e)))
                     (whiteboard-handler/add-new-block-portal-shape!
                      (:block/uuid block)
                      (whiteboard-handler/closest-shape (.-target e)))
@@ -1002,8 +988,7 @@
         (nil? metadata-show)
         (or
          (gp-config/local-asset? s)
-         (text-util/media-link? media-formats s)
-         (= (first s) \@)))
+         (text-util/media-link? media-formats s)))
        (true? (boolean metadata-show))))
 
      ;; markdown
@@ -1237,14 +1222,10 @@
   [:div.dsl-query.pr-3.sm:pr-0
    (let [query (->> (string/join ", " arguments)
                     (string/trim))]
-     (when-not (string/blank? query)
-       (custom-query (assoc config :dsl-query? true)
-                     {:title (ui/tippy {:html commands/query-doc
-                                        :interactive true
-                                        :in-editor?  true}
-                                       [:span.font-medium.px-2.py-1.query-title.text-sm.rounded-md.shadow-xs
-                                        (str "Query: " query)])
-                      :query query})))])
+     (custom-query (assoc config :dsl-query? true)
+                   {:title (rum/with-key (query-builder-component/builder query config)
+                             query)
+                    :query query}))])
 
 (defn- macro-function-cp
   [config arguments]
@@ -1637,7 +1618,7 @@
 
          ["Cookie" ["Percent" n]]
          [:span {:class "cookie-percent"}
-          (util/format "[d%%]" n)]
+          (util/format "[%d%%]" n)]
          ["Cookie" ["Absolute" current total]]
          [:span {:class "cookie-absolute"}
           (util/format "[%d/%d]" current total)]
@@ -1925,7 +1906,7 @@
         priority (priority-cp t)
         tags (block-tags-cp t)
         bg-color (:background-color properties)
-        ;; `heading-level` is for backward compatiblity, will remove it in later releases
+        ;; `heading-level` is for backward compatibility, will remove it in later releases
         heading-level (:block/heading-level t)
         heading (or
                  (and heading-level
@@ -1951,43 +1932,52 @@
                                      bg-color)
                  :color (when-not (some #{bg-color} ui/block-background-colors) "white")}
          :class "px-1 with-bg-color"}))
-     (remove-nils
-      (concat
-       [(when-not slide? checkbox)
-        (when-not slide? marker-switch)
-        marker-cp
-        priority]
-       (if title
-         (conj
-          (map-inline config title)
-          (when (= block-type :whiteboard-shape) [:span.mr-1 (ui/icon "whiteboard-element" {:extension? true})])
-          (when (and
-                 (or config/publishing? (util/electron?))
-                 (not (#{:default :whiteboard-shape} block-type)))
-            (let [area? (= :area (keyword (:hl-type properties)))]
-              [:div.prefix-link
-               {:on-mouse-down (fn [^js e]
-                                 (let [^js target (.-target e)]
-                                   (case block-type
-                                     ;; pdf annotation
-                                     :annotation
-                                     (if (and area? (.contains (.-classList target) "blank"))
-                                       :actions
-                                       (do
-                                         (pdf-assets/open-block-ref! t)
-                                         (util/stop e)))
-
-                                     :dune)))}
-
-               [:span.hl-page
-                [:strong.forbid-edit (str "P" (or (:hl-page properties) "?"))]
-                [:label.blank " "]]
-
-               (when (and area? (:hl-stamp properties))
-                 (pdf-assets/area-display t))])))
-
-         [[:span.opacity-50 "Click here to start writing, type '/' to see all the commands."]])
-       [tags])))))
+
+     ;; children
+     (let [area?  (= :area (keyword (:hl-type properties)))
+           hl-ref #(when (and (or config/publishing? (util/electron?))
+                              (not (#{:default :whiteboard-shape} block-type)))
+                     [:div.prefix-link
+                      {:on-mouse-down
+                       (fn [^js e]
+                         (let [^js target (.-target e)]
+                           (case block-type
+                             ;; pdf annotation
+                             :annotation
+                             (if (and area? (.contains (.-classList target) "blank"))
+                               :actions
+                               (do
+                                 (pdf-assets/open-block-ref! t)
+                                 (util/stop e)))
+
+                             :dune)))}
+
+                      [:span.hl-page
+                       [:strong.forbid-edit (str "P" (or (:hl-page properties) "?"))]
+                       [:label.blank " "]]
+
+                      (when (and area? (:hl-stamp properties))
+                        (pdf-assets/area-display t))])]
+       (remove-nils
+        (concat
+         [(when-not slide? checkbox)
+          (when-not slide? marker-switch)
+          marker-cp
+          priority]
+
+         ;; highlight ref block (inline)
+         (when-not area? [(hl-ref)])
+
+         (if title
+           (conj
+            (map-inline config title)
+            (when (= block-type :whiteboard-shape) [:span.mr-1 (ui/icon "whiteboard-element" {:extension? true})]))
+           [[:span.opacity-50 "Click here to start writing, type '/' to see all the commands."]])
+
+         [tags]
+
+         ;; highlight ref block (area)
+         (when area? [(hl-ref)])))))))
 
 (rum/defc span-comma
   []
@@ -2163,19 +2153,23 @@
 (defn- block-content-on-mouse-down
   [e block block-id content edit-input-id]
   (when-not (> (count content) (state/block-content-max-length (state/get-current-repo)))
-    (.stopPropagation e)
     (let [target (gobj/get e "target")
           button (gobj/get e "buttons")
           shift? (gobj/get e "shiftKey")
-          meta? (util/meta-key? e)]
-      (if (and meta? (not (state/get-edit-input-id)))
+          meta? (util/meta-key? e)
+          forbidden-edit? (target-forbidden-edit? target)]
+      (when-not forbidden-edit? (.stopPropagation e))
+      (if (and meta?
+               (not (state/get-edit-input-id))
+               (not (dom/has-class? target "page-ref"))
+               (not= "A" (gobj/get target "tagName")))
         (do
           (util/stop e)
           (state/conj-selection-block! (gdom/getElement block-id) :down)
           (when block-id
             (state/set-selection-start-block! block-id)))
         (when (contains? #{1 0} button)
-          (when-not (target-forbidden-edit? target)
+          (when-not forbidden-edit?
             (cond
               (and shift? (state/get-selection-start-block-or-first))
               (do
@@ -2400,7 +2394,7 @@
                  current-block-page? (= (str (:block/uuid block)) (state/get-current-page))
                  embed-self? (and (:embed? config)
                                   (= (:block/uuid block) (:block/uuid (:block config))))
-                 default-hide? (if (and current-block-page? (not embed-self?)) false true)]
+                 default-hide? (if (and current-block-page? (not embed-self?) (state/auto-expand-block-refs?)) false true)]
              (assoc state ::hide-block-refs? (atom default-hide?))))}
   [state config {:block/keys [uuid format] :as block} edit-input-id block-id edit? hide-block-refs-count?]
   (let [*hide-block-refs? (get state ::hide-block-refs?)
@@ -2872,7 +2866,14 @@
                                         (select-keys b2 compare-keys))
                                   (not= (select-keys (first (:rum/args old-state)) config-compare-keys)
                                         (select-keys (first (:rum/args new-state)) config-compare-keys)))]
-                      (boolean result)))}
+                      (boolean result)))
+   :will-unmount (fn [state]
+                   ;; restore root block's collapsed state
+                   (let [[config block] (:rum/args state)
+                         block-id (:block/uuid block)]
+                     (when (root-block? config block)
+                       (state/set-collapsed-block! block-id nil)))
+                   state)}
   [state config block]
   (let [repo (state/get-current-repo)
         ref? (:ref? config)
@@ -3008,7 +3009,7 @@
   [log]
   (let [clocks (filter #(string/starts-with? % "CLOCK:") log)
         clocks (reverse (sort-by str clocks))
-        ;; TODO: diplay states change log
+        ;; TODO: display states change log
         ; states (filter #(not (string/starts-with? % "CLOCK:")) log)
         ]
     (when (seq clocks)
@@ -3044,53 +3045,50 @@
       (boolean (some #(= % title) (map :title queries))))))
 
 (defn- trigger-custom-query!
-  [state]
-  (let [[config query] (:rum/args state)
+  [state *query-error]
+  (let [[config query _query-result] (:rum/args state)
         repo (state/get-current-repo)
         result-atom (or (:query-atom state) (atom nil))
         current-block-uuid (or (:block/uuid (:block config))
                                (:block/uuid config))
-        [full-text-search? query-atom] (if (:dsl-query? config)
-                                         (let [q (:query query)
-                                               form (safe-read-string q false)]
-                                           (cond
-                                             ;; 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
-                                             [true
-                                              (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)
-                                             [false (atom nil)]
-
-                                             :else
-                                             [false (query-dsl/query (state/get-current-repo) q)]))
-                                         [false (db/custom-query query {:current-block-uuid current-block-uuid})])
-        query-atom (if (instance? Atom query-atom)
-                     query-atom
-                     result-atom)]
-    (assoc state
-           :query-atom query-atom
-           :full-text-search? full-text-search?)))
-
-(defn- clear-custom-query!
-  [dsl? query]
-  (let [query (if dsl? (:query query) query)]
-    (state/remove-custom-query-component! query)
-    (db/remove-custom-query! (state/get-current-repo) query)))
+        _ (reset! *query-error nil)
+        query-atom (try
+                     (cond
+                       (:dsl-query? config)
+                       (let [q (:query query)
+                             form (safe-read-string q false)]
+                         (cond
+                           ;; 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
+                           (query-dsl/query (state/get-current-repo) q)))
+
+                       :else
+                       (db/custom-query query {:current-block-uuid current-block-uuid}))
+                     (catch :default e
+                       (reset! *query-error e)
+                       (atom nil)))]
+    (if (instance? Atom query-atom)
+      query-atom
+      result-atom)))
 
 (rum/defc query-refresh-button
-  [state query-time {:keys [on-mouse-down]}]
+  [query-time {:keys [on-mouse-down full-text-search?]}]
   (ui/tippy
    {:html  [:div
             [:p
-             (when (and query-time (> query-time 80))
-               [:span (str "This query takes " (int query-time) "ms to finish, it's a bit slow so that auto refresh is disabled.")])
-             (when (:full-text-search? state)
-               [:span "Full-text search results will not be refreshed automatically."])]
+             (if full-text-search?
+               [:span "Full-text search results will not be refreshed automatically."]
+               [:span (str "This query takes " (int query-time) "ms to finish, it's a bit slow so that auto refresh is disabled.")])]
             [:p
              "Click the refresh button instead if you want to see the latest result."]]
     :interactive     true
@@ -3098,163 +3096,212 @@
                                   {:enabled           true
                                    :boundariesElement "viewport"}}}
     :arrow true}
-   [:a.control.fade-link.ml-1.inline-flex
-    {:style {:margin-top 7}
-     :on-mouse-down on-mouse-down}
+   [:a.fade-link.flex
+    {:on-mouse-down on-mouse-down}
     (ui/icon "refresh" {:style {:font-size 20}})]))
 
-(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))]
-                  (state/add-custom-query-component! query (:rum/react-component state)))
-                state)
-   :will-unmount (fn [state]
-                   (when-let [query (last (:rum/args state))]
-                     (clear-custom-query! (:dsl-query? (first (:rum/args state)))
-                                          query))
-                   state)}
-  [state config {:keys [title query view collapsed? children? breadcrumb-show? table-view?] :as q}]
-  (let [dsl-query? (:dsl-query? config)
-        query-atom (:query-atom state)
-        query-time (or (react/get-query-time query)
-                       (react/get-query-time q))
-        view-fn (if (keyword? view) (get-in (state/sub-config) [:query/views view]) view)
-        current-block-uuid (or (:block/uuid (:block config))
-                               (:block/uuid config))
-        current-block (db/entity [:block/uuid current-block-uuid])
+(rum/defcs custom-query-inner < rum/reactive db-mixins/query
+  [state config {:keys [query children? breadcrumb-show?] :as q}
+   {:keys [query-result-atom
+           query-error-atom
+           current-block
+           current-block-uuid
+           table?
+           dsl-query?
+           page-list?
+           built-in-query?
+           view-f]}]
+  (let [*query-error query-error-atom
+        query-atom (if built-in-query? query-result-atom (trigger-custom-query! state *query-error))
+        query-result (and query-atom (rum/react query-atom))
         ;; 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 table-view?
-                   (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)
+                 (let [result (db-utils/group-by-page transformed-query-result)]
+                   (if (map? result)
+                     (dissoc result nil)
+                     result))
                  transformed-query-result)
+        _ (when (and query-result-atom (not built-in-query?))
+            (reset! query-result-atom (util/safe-with-meta result (meta @query-atom))))
         _ (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-fn (sci/eval-string (pr-str view-fn)))
         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)
+                                     true)]
+    (if @*query-error
+      (do
+        (log/error :exception @*query-error)
+        [:div.warning.my-1 "Query failed: "
+         [:p (.-message @*query-error)]])
+      [:div.custom-query-results
+       (cond
+         (and (seq result) view-f)
+         (let [result (try
+                        (sci/call-fn view-f result)
+                        (catch :default 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
+                                         :dsl-query? dsl-query?
+                                         :query query
+                                         :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?))]
+           (when (seq result)
+             [:ul
+              (for [item result]
+                [:li (str item)])]))
+
+         (or (string/blank? query)
+             (= query "(and)"))
+         nil
+
+         :else
+         [:div.text-sm.mt-2.opacity-90 "No matched result"])])))
+
+(rum/defc query-title
+  [config title]
+  [:div.custom-query-title.flex.justify-between.w-full
+   [:span.title-text (cond
+                       (vector? title) title
+                       (string? title) (inline-text config
+                                                    (get-in config [:block :block/format] :markdown)
+                                                    title)
+                       :else title)]])
+
+(rum/defcs ^:large-vars/cleanup-todo custom-query* < rum/reactive
+  (rum/local nil ::query-result)
+  {:init (fn [state] (assoc state :query-error (atom nil)))}
+  [state config {:keys [title query view collapsed? table-view?] :as q}]
+  (let [*query-error (:query-error state)
         built-in? (built-in-custom-query? title)
+        *query-result (if built-in?
+                        (trigger-custom-query! state *query-error)
+                        (::query-result state))
+        result (rum/react *query-result)
+        dsl-query? (:dsl-query? config)
+        current-block-uuid (or (:block/uuid (:block config))
+                               (:block/uuid config))
+        current-block (db/entity [:block/uuid current-block-uuid])
+        temp-collapsed? (state/sub-collapsed current-block-uuid)
+        collapsed?' (if (some? temp-collapsed?)
+                      temp-collapsed?
+                      (or
+                       collapsed?
+                       (:block/collapsed? current-block)))
+        table? (or table-view?
+                   (get-in current-block [:block/properties :query-table])
+                   (and (string? query) (string/ends-with? (string/trim query) "table")))
+        query-time (:query-time (meta @*query-result))
+        view-fn (if (keyword? view) (get-in (state/sub-config) [:query/views view]) view)
+        view-f (and view-fn (sci/eval-string (pr-str view-fn)))
         page-list? (and (seq result)
-                        (:block/name (first result)))
-        nested-query? (:custom-query? config)]
-    (if nested-query?
+                        (some? (:block/name (first result))))
+        dsl-page-query? (and dsl-query?
+                             (false? (:blocks? (query-dsl/parse-query query))))
+        full-text-search? (and dsl-query?
+                               (util/electron?)
+                               (symbol? (safe-read-string query false)))]
+    (if (:custom-query? config)
       [:code (if dsl-query?
                (util/format "{{query %s}}" query)
                "{{query hidden}}")]
       (when-not (and built-in? (empty? result))
-        [:div.custom-query.mt-4 (get config :attr {})
-         (ui/foldable
-          [:div.custom-query-title.flex.justify-between.w-full
-           [:div.flex.items-center
-            [:span.title-text (cond
-                                (vector? title) title
-                                (string? title) (inline-text config
-                                                             (get-in config [:block :block/format] :markdown)
-                                                             title)
-                                :else title)]
-           [:span.opacity-60.text-sm.ml-2.results-count
-            (str (count result) " results")]]
-
-           ;;insert an "edit" button in the query view
-           [:div.flex.items-center
-            (when-not built-in?
-              [:a.opacity-70.hover:opacity-100.svg-small.inline
-               {:on-mouse-down (fn [e]
-                                 (util/stop e)
-                                 (editor-handler/edit-block! current-block :max (:block/uuid current-block)))}
-               svg/edit])
-
-            (when (or (:full-text-search? state)
-                      (and query-time (> query-time 80)))
-              (query-refresh-button state query-time
-                                    {:on-mouse-down (fn [e]
-                                                      (util/stop e)
-                                                      (trigger-custom-query! state))}))]]
-          (fn []
-            [:div
-             (when (and current-block (not view-f) (nil? table-view?))
-               [: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 :default 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
-                                               :dsl-query? dsl-query?
-                                               :query query
-                                               :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
-           :on-mouse-down (fn [collapsed?]
-                            (when collapsed?
-                              (clear-custom-query! dsl-query? q)))})]))))
+        (let [opts {:query-result-atom *query-result
+                    :query-error-atom *query-error
+                    :current-block current-block
+                    :dsl-query? dsl-query?
+                    :current-block-uuid current-block-uuid
+                    :table? table?
+                    :view-f view-f
+                    :page-list? page-list?
+                    :built-in-query? built-in?}]
+          [:div.custom-query (get config :attr {})
+           (when-not built-in?
+             [:div.th
+              [:div.flex.flex-1.flex-row
+               (ui/icon "search" {:size 14})
+               [:div.ml-1 (str "Live query" (when dsl-page-query? " for pages"))]]
+              (when-not collapsed?'
+                [:div.flex.flex-row.items-center.fade-in
+                 (when (> (count result) 0)
+                   [:span.results-count
+                    (str (count result) (if (> (count result) 1) " results" " result"))])
+
+                 (when (and current-block (not view-f) (nil? table-view?) (not page-list?))
+                   (if table?
+                     [:a.flex.ml-1.fade-link {:title "Switch to list view"
+                                              :on-click (fn [] (editor-handler/set-block-property! current-block-uuid
+                                                                                                   "query-table"
+                                                                                                   false))}
+                      (ui/icon "list" {:style {:font-size 20}})]
+                     [:a.flex.ml-1.fade-link {:title "Switch to table view"
+                                              :on-click (fn [] (editor-handler/set-block-property! current-block-uuid
+                                                                                                   "query-table"
+                                                                                                   true))}
+                      (ui/icon "table" {:style {:font-size 20}})]))
+
+                 [:a.flex.ml-1.fade-link
+                  {:title "Setting properties"
+                   :on-click (fn []
+                               (let [all-keys (query-table/get-keys result page-list?)]
+                                 (state/pub-event! [:modal/set-query-properties current-block all-keys])))}
+                  (ui/icon "settings" {:style {:font-size 20}})]
+
+                 [:div.ml-1
+                  (when (or full-text-search?
+                            (and query-time (> query-time 50)))
+                    (query-refresh-button query-time {:full-text-search? full-text-search?
+                                                      :on-mouse-down (fn [e]
+                                                                       (util/stop e)
+                                                                       (trigger-custom-query! state *query-error))}))]])])
+           (if built-in?
+             (ui/foldable
+              (query-title config title)
+              (fn []
+                (custom-query-inner config q opts))
+              {})
+             [:div.bd
+              (query-title config title)
+              (when-not collapsed?'
+                (custom-query-inner config q opts))])])))))
 
 (rum/defc custom-query
   [config q]
@@ -3662,8 +3709,13 @@
                   (page-cp config page)
                   (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
                  (for [[parent blocks] parent-blocks]
-                   (let [blocks' (map #(update % :block/children (fn [col]
-                                                                   (tree/non-consecutive-blocks->vec-tree col))) blocks)]
+                   (let [blocks' (map (fn [b]
+                                        ;; Block might be a datascript entity
+                                        (if (e/entity? b)
+                                          (db/pull (:db/id b))
+                                          (update b :block/children
+                                                  (fn [col]
+                                                    (tree/non-consecutive-blocks->vec-tree col))))) blocks)]
                      (rum/with-key
                        (breadcrumb-with-container blocks' config)
                        (:db/id parent))))

+ 6 - 0
src/main/frontend/components/block.css

@@ -538,6 +538,12 @@ a:hover > .bullet-container {
   }
 }
 
+.ls-block .custom-query {
+  > .th {
+    @apply flex flex-row flex-1 items-center justify-between my-1 text-xs opacity-90;
+  }
+}
+
 /* copied from https://github.com/drdogbot7/tailwindcss-responsive-embed */
 .embed-responsive {
   position: relative;

+ 64 - 32
src/main/frontend/components/commit.cljs

@@ -1,14 +1,16 @@
 (ns frontend.components.commit
-  (:require [electron.ipc :as ipc]
+  (:require [clojure.string :as string]
+            [electron.ipc :as ipc]
             [frontend.mixins :as mixins]
             [frontend.state :as state]
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
             [goog.dom :as gdom]
             [goog.object :as gobj]
+            [promesa.core :as p]
             [rum.core :as rum]))
 
-(defn commit-and-push!
+(defn- commit-all!
   []
   (let [value (gobj/get (gdom/getElement "commit-message") "value")]
     (when (and value (>= (count value) 1))
@@ -16,8 +18,31 @@
         (ipc/ipc "gitCommitAll" value))
       (state/close-modal!))))
 
-(rum/defcs add-commit-message <
-  {:did-update (fn [state]
+(defn prettify-git-status
+  [status]
+  (let [lines (string/split-lines status)]
+    (->> lines
+         (remove empty?)
+         (map (fn [line]
+                (let [first-char (first (string/trim line))]
+                  (cond
+                    (= first-char "#") [:span line] ;; TODO: handle `--branch` info
+                    (= first-char "M") [:span.text-green-400 line]
+                    (= first-char "A") [:span.text-green-500 line]
+                    (= first-char "D") [:span.text-red-500 line]
+                    (= first-char "?") [:span.text-green-500 line]
+                    :else line))))
+         (interpose [:br]))))
+
+
+(rum/defcs add-commit-message < rum/reactive
+  (rum/local nil ::git-status)
+  {:will-mount (fn [state]
+                 (-> (ipc/ipc "gitStatus")
+                     (p/then (fn [status]
+                               (reset! (get state ::git-status) status))))
+                 state)
+   :did-update (fn [state]
                  (when-let [input (gdom/getElement "commit-message")]
                    (.focus input)
                    (cursor/move-cursor-to-end input))
@@ -27,35 +52,42 @@
      (mixins/on-enter state
                       :node (gdom/getElement "commit-message")
                       :on-enter (fn []
-                                  (commit-and-push!)))))
+                                  (commit-all!)))))
   [state _close-fn]
-  [:div.w-full.mx-auto {:style {:padding "48px 0"}}
-   [:div.sm:flex.sm:items-start
-    [:div.mt-3.text-center.sm:mt-0.sm:text-left.mb-2
-     [:h3#modal-headline.text-lg.leading-6.font-medium
-      "Your commit message:"]]]
-
-   [:input#commit-message.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
-    {:auto-focus true
-     :default-value ""}]
+  (let [*git-status (get state ::git-status)]
+    [:div.w-full.mx-auto.sm:max-w-lg.sm:w-96 {:style {:padding "48px 0"}}
+     (if (empty? @*git-status)
+       [:<>
+        [:div.sm:flex.sm:items-start
+         [:div.mt-3.text-center.sm:mt-0.sm:text-left.mb-2
+          [:h3#modal-headline.text-lg.leading-6.font-medium
+           "No changes to commit!"]]]
+        [:div.mt-5.sm:mt-4.flex
+         [:span.flex.w-full.rounded-md.shadow-sm
+          [:button.inline-flex.justify-center.w-full.rounded-md.border.border-transparent.px-4.py-2.bg-indigo-600.text-base.leading-6.font-medium.text-white.shadow-sm.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
+           {:type "button"
+            :on-click #(state/close-modal!)}
+           "Close"]]]]
 
-   [:div.mt-5.sm:mt-4.flex
-    [:span.flex.w-full.rounded-md.shadow-sm
-     [:button.inline-flex.justify-center.w-full.rounded-md.border.border-transparent.px-4.py-2.bg-indigo-600.text-base.leading-6.font-medium.text-white.shadow-sm.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
-      {:type "button"
-       :on-click commit-and-push!}
-      "Commit"]]]])
+       [:<>
+        [:div.sm:flex.sm:items-start
+         [:div.mt-3.text-center.sm:mt-0.sm:text-left.mb-2
+          (if (nil? @*git-status)
+            [:div "Loading..."]
+            [:div "You have uncommitted changes"
+             [:pre (prettify-git-status @*git-status)]])
+          [:h3#modal-headline.text-lg.leading-6.font-medium
+           "Your commit message:"]]]
+        [:input#commit-message.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2
+         {:auto-focus true
+          :default-value ""}]
+        [:div.mt-5.sm:mt-4.flex
+         [:span.flex.w-full.rounded-md.shadow-sm
+          [:button.inline-flex.justify-center.w-full.rounded-md.border.border-transparent.px-4.py-2.bg-indigo-600.text-base.leading-6.font-medium.text-white.shadow-sm.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
+           {:type "button"
+            :on-click commit-all!}
+           "Commit"]]]])]))
 
 (defn show-commit-modal! [e]
-  (when (and
-         (util/electron?)
-         (not (util/input? (gobj/get e "target")))
-         (not (gobj/get e "shiftKey"))
-         (not (gobj/get e "ctrlKey"))
-         (not (gobj/get e "altKey"))
-         (not (gobj/get e "metaKey")))
-    #_:clj-kondo/ignore
-    (when-let [repo-url (state/get-current-repo)]
-      (when-not (state/get-edit-input-id)
-        (util/stop e)
-        (state/set-modal! add-commit-message)))))
+  (state/set-modal! add-commit-message)
+  (when e (util/stop e)))

+ 5 - 5
src/main/frontend/components/content.cljs

@@ -33,7 +33,7 @@
    (ui/menu-link
     {:key "cut"
      :on-click #(editor-handler/cut-selection-blocks true)}
-    "Cut"
+    (t :content/cut)
     nil)
    (ui/menu-link
     {:key      "delete"
@@ -44,7 +44,7 @@
    (ui/menu-link
     {:key "copy"
      :on-click editor-handler/copy-selection-blocks}
-    "Copy"
+    (t :content/copy)
     nil)
    (ui/menu-link
     {:key "copy as"
@@ -236,7 +236,7 @@
           {:key      "Cut"
            :on-click (fn [_e]
                        (editor-handler/cut-block! block-id))}
-          "Cut"
+          (t :content/cut)
           nil)
 
          (ui/menu-link
@@ -400,7 +400,7 @@
   [:div {:id id}
    (if hiccup
      hiccup
-     [:div.cursor "Click to edit"])])
+     [:div.cursor (t :content/click-to-edit)])])
 
 (rum/defc non-hiccup-content < rum/reactive
   [id content on-click on-hide config format]
@@ -422,7 +422,7 @@
          {:id id
           :on-click on-click}
          (if (string/blank? content)
-           [:div.cursor "Click to edit"]
+           [:div.cursor (t :content/click-to-edit)]
            content)]))))
 
 (defn- set-draw-iframe-style!

+ 3 - 3
src/main/frontend/components/datetime.cljs

@@ -62,7 +62,7 @@
           {:label "w"}
           {:label "m"}
           {:label "y"}])
-        (fn [value]
+        (fn [_e value]
           (swap! *timestamp assoc-in [:repeater :duration] value))
         nil)
 
@@ -132,7 +132,7 @@
 
      [:p.mt-4
       (ui/button "Submit"
-        :on-click on-submit)]]))
+                 :on-click on-submit)]]))
 
 (rum/defc date-picker < rum/reactive
   {:init (fn [state]
@@ -161,7 +161,7 @@
          (util/stop e)
          (let [date (t/to-default-time-zone date)
                journal (date/journal-name date)]
-           ;; deadline-or-schedule? is handled in on-sumbit, not here
+           ;; deadline-or-schedule? is handled in on-submit, not here
            (when-not deadline-or-schedule?
                ;; similar to page reference
              (editor-handler/insert-command! dom-id

+ 25 - 16
src/main/frontend/components/editor.cljs

@@ -340,20 +340,30 @@
 
 (rum/defc absolute-modal < rum/static
   [cp modal-name set-default-width? {:keys [top left rect]}]
-  (let [vw-width js/window.innerWidth
+  (let [MAX-HEIGHT 700
+        MAX-HEIGHT' 600
+        MAX-WIDTH 600
+        SM-MAX-WIDTH 300
+        Y-BOUNDARY-HEIGHT 150
+        vw-width js/window.innerWidth
         vw-height js/window.innerHeight
         vw-max-width (- vw-width (:left rect))
         vw-max-height (- vw-height (:top rect))
+        vw-max-height' (:top rect)
         sm? (< vw-width 415)
-        max-height (min (- vw-max-height 20) 800)
-        max-width (if sm? 300 (min (max 400 (/ vw-max-width 2)) 600))
+        max-height (min (- vw-max-height 20) MAX-HEIGHT)
+        max-height' (min (- vw-max-height' 70) MAX-HEIGHT')
+        max-width (if sm? SM-MAX-WIDTH (min (max 400 (/ vw-max-width 2)) MAX-WIDTH))
         offset-top 24
-        to-max-height (if (and (seq rect) (> vw-height max-height))
-                        (let [delta-height (- vw-height (+ (:top rect) top offset-top))]
-                          (if (< delta-height max-height)
-                            (- (max (* 2 offset-top) delta-height) 16)
-                            max-height))
-                        max-height)
+        to-max-height (cond-> (if (and (seq rect) (> vw-height max-height))
+                                (let [delta-height (- vw-height (+ (:top rect) top offset-top))]
+                                  (if (< delta-height max-height)
+                                    (- (max (* 2 offset-top) delta-height) 16)
+                                    max-height))
+                                max-height)
+
+                              (= modal-name "commands")
+                              (min 500))
         right-sidebar? (:ui/sidebar-open? @state/state)
         editing-key    (first (keys (:editor/editing? @state/state)))
         *el (rum/use-ref nil)
@@ -367,8 +377,9 @@
                                    (when (> ofx 0)
                                      (set! (.-transform (.-style el)) (str "translateX(-" (+ ofx 20) "px)")))))))
                            [right-sidebar? editing-key])
-        y-overflow-vh? (< to-max-height 130)
-        to-max-height (if y-overflow-vh? max-height to-max-height)
+        y-overflow-vh? (or (< to-max-height Y-BOUNDARY-HEIGHT)
+                           (> (- max-height' to-max-height) Y-BOUNDARY-HEIGHT))
+        to-max-height (if y-overflow-vh? max-height' to-max-height)
         pos-rect (when (and (seq rect) editing-key)
                    (:rect (cursor/get-caret-pos (state/get-input))))
         y-diff (when pos-rect (- (:height pos-rect) (:height rect)))
@@ -381,11 +392,9 @@
                 :z-index    11}
                (when set-default-width?
                  {:width max-width})
-               (when-let [^js/HTMLElement editor
-                          (js/document.querySelector ".editor-wrapper")]
-                 (if (<= (.-clientWidth editor) (+ left (if set-default-width? max-width 500)))
-                   {:right 0}
-                   {:left (if (or (nil? y-diff) (and y-diff (= y-diff 0))) left 0)})))]
+               (if (<= vw-max-width (+ left (if set-default-width? max-width 500)))
+                 {:right 0}
+                 {:left (if (or (nil? y-diff) (and y-diff (= y-diff 0))) left 0)}))]
     [:div.absolute.rounded-md.shadow-lg.absolute-modal
      {:ref *el
       :data-modal-name modal-name

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

@@ -40,6 +40,13 @@
   &.is-overflow-vh-y {
     transform: translateY(calc(-100% - 2rem));
   }
+
+  &[data-modal-name="commands"] {
+    @screen sm {
+      width: 380px !important;
+      max-width: 90vw !important;
+    }
+  }
 }
 
 .is-mobile {

+ 130 - 52
src/main/frontend/components/export.cljs

@@ -1,5 +1,8 @@
 (ns frontend.components.export
   (:require [frontend.context.i18n :refer [t]]
+            [frontend.handler.export.text :as export-text]
+            [frontend.handler.export.html :as export-html]
+            [frontend.handler.export.opml :as export-opml]
             [frontend.handler.export :as export]
             [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
@@ -11,7 +14,7 @@
   []
   (when-let [current-repo (state/get-current-repo)]
     [:div.export
-     [:h1.title "Export"]
+     [:h1.title (t :export)]
      [:ul.mr-1
       [:li.mb-4
        [:a.font-medium {:on-click #(export/export-repo-as-edn-v2! current-repo)}
@@ -21,19 +24,20 @@
         (t :export-json)]]
       (when (util/electron?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-html! current-repo)}
+         [:a.font-medium {:on-click #(export/download-repo-as-html! current-repo)}
           (t :export-public-pages)]])
       (when-not (mobile-util/native-platform?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-markdown! current-repo)}
-          (t :export-markdown)]]
+         [:a.font-medium {:on-click #(export-text/export-repo-as-markdown! current-repo)}
+          (t :export-markdown)]])
+      (when-not (mobile-util/native-platform?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-opml! current-repo)}
+         [:a.font-medium {:on-click #(export-opml/export-repo-as-opml! current-repo)}
           (t :export-opml)]])
       (when-not (mobile-util/native-platform?)
-       [:li.mb-4
-        [:a.font-medium {:on-click #(export/export-repo-as-roam-json! current-repo)}
-         (t :export-roam-json)]])]
+        [:li.mb-4
+         [:a.font-medium {:on-click #(export/export-repo-as-roam-json! current-repo)}
+          (t :export-roam-json)]])]
      [:a#download-as-edn-v2.hidden]
      [:a#download-as-json-v2.hidden]
      [:a#download-as-roam-json.hidden]
@@ -53,49 +57,76 @@
                                 {:label "no-indent"
                                  :selected false}])
 
-(rum/defcs export-blocks
-  < rum/reactive
-  (rum/local false ::copied?)
-  [state root-block-ids]
+(defn- export-helper
+  [block-uuids-or-page-name]
   (let [current-repo (state/get-current-repo)
-        type (rum/react *export-block-type)
-        text-indent-style (state/sub :copy/export-block-text-indent-style)
-        text-remove-options (state/sub :copy/export-block-text-remove-options)
-        copied? (::copied? state)
-        content
-        (case type
-          :text (export/export-blocks-as-markdown current-repo root-block-ids text-indent-style (into [] text-remove-options))
-          :opml (export/export-blocks-as-opml current-repo root-block-ids)
-          :html (export/export-blocks-as-html current-repo root-block-ids)
-          (export/export-blocks-as-markdown current-repo root-block-ids text-indent-style (into [] text-remove-options)))]
+        text-indent-style (state/get-export-block-text-indent-style)
+        text-remove-options (set (state/get-export-block-text-remove-options))
+        text-other-options (state/get-export-block-text-other-options)
+        tp @*export-block-type]
+    (case tp
+      :text (export-text/export-blocks-as-markdown
+             current-repo block-uuids-or-page-name
+             {:indent-style text-indent-style :remove-options text-remove-options :other-options text-other-options})
+      :opml (export-opml/export-blocks-as-opml
+             current-repo block-uuids-or-page-name {:remove-options text-remove-options :other-options text-other-options})
+      :html (export-html/export-blocks-as-html
+             current-repo block-uuids-or-page-name {:remove-options text-remove-options :other-options text-other-options})
+      "")))
+
+(rum/defcs ^:large-vars/cleanup-todo
+  export-blocks < rum/static
+  (rum/local false ::copied?)
+  (rum/local nil ::text-remove-options)
+  (rum/local nil ::text-indent-style)
+  (rum/local nil ::text-other-options)
+  (rum/local nil ::content)
+  {:will-mount (fn [state]
+                 (let [content (export-helper (last (:rum/args state)))]
+                   (reset! (::content state) content)
+                   (reset! (::text-remove-options state) (set (state/get-export-block-text-remove-options)))
+                   (reset! (::text-indent-style state) (state/get-export-block-text-indent-style))
+                   (reset! (::text-other-options state) (state/get-export-block-text-other-options))
+                   state))}
+  [state root-block-uuids-or-page-name]
+  (let [tp @*export-block-type
+        *text-other-options (::text-other-options state)
+        *text-remove-options (::text-remove-options state)
+        *text-indent-style (::text-indent-style state)
+        *copied? (::copied? state)
+        *content (::content state)]
     [:div.export.resize
      [:div.flex
       {:class "mb-2"}
       (ui/button "Text"
                  :class "mr-4 w-20"
-                 :on-click #(reset! *export-block-type :text))
+                 :on-click #(do (reset! *export-block-type :text)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))
       (ui/button "OPML"
                  :class "mr-4 w-20"
-                 :on-click #(reset! *export-block-type :opml))
+                 :on-click #(do (reset! *export-block-type :opml)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))
       (ui/button "HTML"
                  :class "w-20"
-                 :on-click #(reset! *export-block-type :html))]
-     [:textarea.overflow-y-auto.h-96 {:value content}]
+                 :on-click #(do (reset! *export-block-type :html)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))]
+     [:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}]
      (let [options (->> text-indent-style-options
                         (mapv (fn [opt]
-                                (if (= text-indent-style (:label opt))
+                                (if (= @*text-indent-style (:label opt))
                                   (assoc opt :selected true)
                                   opt))))]
        [:div [:div.flex.items-center
               [:label.mr-4
-               {:style {:visibility (if (= :text type) "visible" "hidden")}}
+               {:style {:visibility (if (= :text tp) "visible" "hidden")}}
                "Indentation style:"]
-              [:select.block.my-2.text-lg.rounded.border
-               {:style     {:padding "0 0 0 12px"
-                            :visibility (if (= :text type) "visible" "hidden")}
+              [:select.block.my-2.text-lg.rounded.border.py-0.px-1
+               {:style     {:visibility (if (= :text tp) "visible" "hidden")}
                 :on-change (fn [e]
                              (let [value (util/evalue e)]
-                               (state/set-export-block-text-indent-style! value)))}
+                               (state/set-export-block-text-indent-style! value)
+                               (reset! *text-indent-style value)
+                               (reset! *content (export-helper root-block-uuids-or-page-name))))}
                (for [{:keys [label value selected]} options]
                  [:option (cond->
                            {:key   label
@@ -104,30 +135,77 @@
                             (assoc :selected selected))
                   label])]]
         [:div.flex.items-center
-         (ui/checkbox {:style {:margin-right 6
-                               :visibility (if (= :text type) "visible" "hidden")}
-                       :checked (contains? text-remove-options :page-ref)
+         (ui/checkbox {:class "mr-2"
+                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :page-ref)
                        :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :page-ref))})
-
-         [:div
-          {:style {:visibility (if (= :text type) "visible" "hidden")}}
+                                    (state/update-export-block-text-remove-options! e :page-ref)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
           "[[text]] -> text"]
 
-         (ui/checkbox {:style {:margin-right 6
-                               :margin-left "1em"
-                               :visibility (if (= :text type) "visible" "hidden")}
-                       :checked (contains? text-remove-options :emphasis)
+         (ui/checkbox {:class "mr-2 ml-4"
+                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :emphasis)
+                       :on-change (fn [e]
+                                    (state/update-export-block-text-remove-options! e :emphasis)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+          "remove emphasis"]
+
+         (ui/checkbox {:class "mr-2 ml-4"
+                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :tag)
+                       :on-change (fn [e]
+                                    (state/update-export-block-text-remove-options! e :tag)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+          "remove #tags"]]
+
+        [:div.flex.items-center
+         (ui/checkbox {:class "mr-2"
+                       :style {:visibility (if (#{:text} tp) "visible" "hidden")}
+                       :checked (boolean (:newline-after-block @*text-other-options))
+                       :on-change (fn [e]
+                                    (state/update-export-block-text-other-options!
+                                     :newline-after-block (boolean (util/echecked? e)))
+                                    (reset! *text-other-options (state/get-export-block-text-other-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+         [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
+          "newline after block"]
+
+         (ui/checkbox {:class "mr-2 ml-4"
+                       :style {:visibility (if (#{:text} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :property)
                        :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :emphasis))})
+                                    (state/update-export-block-text-remove-options! e :property)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+         [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
+          "remove properties"]]
 
-         [:div
-          {:style {:visibility (if (= :text type) "visible" "hidden")}}
-          "remove emphasis"]]])
+        [:div.flex.items-center
+         [:label.mr-2 {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+          "level <="]
+         [:select.block.my-2.text-lg.rounded.border.px-2.py-0
+          {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+           :value (or (:keep-only-level<=N @*text-other-options) :all)
+           :on-change (fn [e]
+                        (let [value (util/evalue e)
+                              level (if (= "all" value) :all (util/safe-parse-int value))]
+                          (state/update-export-block-text-other-options! :keep-only-level<=N level)
+                          (reset! *text-other-options (state/get-export-block-text-other-options))
+                          (reset! *content (export-helper root-block-uuids-or-page-name))))}
+          (for [n (cons "all" (range 1 10))]
+            [:option {:key n :value n} n])]]])
 
      [:div.mt-4
-      (ui/button (if @copied? "Copied to clipboard!" "Copy to clipboard")
-        :on-click (fn []
-                    (util/copy-to-clipboard! content (when (= type :html)
-                                                       content))
-                    (reset! copied? true)))]]))
+      (ui/button (if @*copied? "Copied to clipboard!" "Copy to clipboard")
+                 :on-click (fn []
+                             (util/copy-to-clipboard! @*content (when (= tp :html) @*content))
+                             (reset! *copied? true)))]]))

+ 52 - 18
src/main/frontend/components/file.cljs

@@ -11,11 +11,15 @@
             [frontend.handler.export :as export-handler]
             [frontend.state :as state]
             [frontend.util :as util]
+            [frontend.fs :as fs]
+            [frontend.config :as config]
+            [frontend.ui :as ui]
             [logseq.graph-parser.config :as gp-config]
             [logseq.graph-parser.util :as gp-util]
             [goog.object :as gobj]
             [reitit.frontend.easy :as rfe]
-            [rum.core :as rum]))
+            [rum.core :as rum]
+            [promesa.core :as p]))
 
 (defn- get-path
   [state]
@@ -66,18 +70,36 @@
    (files-all)
    ])
 
-(rum/defcs file < rum/reactive
-  {:did-mount (fn [state]
+(rum/defcs file-inner < rum/reactive
+  {:will-mount (fn [state]
+                 (let [*content (atom nil)
+                       [path format] (:rum/args state)
+                       repo-dir (config/get-repo-dir (state/get-current-repo))
+                       [dir path] (cond
+                                    (string/starts-with? path repo-dir)
+                                    [repo-dir (-> (string/replace-first path repo-dir "")
+                                                  (string/replace #"^/+" ""))]
+
+                                    ;; browser-fs
+                                    (not (string/starts-with? path "/"))
+                                    [repo-dir path]
+
+                                    :else
+                                    ["" path])]
+                   (when (and format (contains? (gp-config/text-formats) format))
+                     (p/let [content (fs/read-file dir path)]
+                       (reset! *content (or content ""))))
+                   (assoc state ::file-content *content)))
+   :did-mount (fn [state]
                 (state/set-file-component! (:rum/react-component state))
                 state)
    :will-unmount (fn [state]
                    (state/clear-file-component!)
                    state)}
-  [state]
-  (let [path (get-path state)
-        format (gp-util/get-format path)
-        original-name (db/get-file-page path)
-        random-id (str (d/squuid))]
+  [state path format]
+  (let [original-name (db/get-file-page path)
+        random-id (str (d/squuid))
+        content (rum/react (::file-content state))]
     [:div.file {:id (str "file-edit-wrapper-" random-id)
                 :key path}
      [:h1.title
@@ -107,16 +129,28 @@
        (and format (contains? (gp-config/img-formats) format))
        [:img {:src (util/node-path.join "file://" path)}]
 
-       (and format (contains? (gp-config/text-formats) format))
-       (when-let [file-content (or (db/get-file path) "")]
-         (let [content (string/trim file-content)
-               mode (util/get-file-ext path)]
-            (lazy-editor/editor {:file?     true
-                                 :file-path path}
-                                (str "file-edit-" random-id)
-                                {:data-lang mode}
-                                content
-                               {})))
+       (and format
+            (contains? (gp-config/text-formats) format)
+            content)
+       (let [content' (string/trim content)
+             mode (util/get-file-ext path)]
+         (lazy-editor/editor {:file?     true
+                              :file-path path}
+                             (str "file-edit-" random-id)
+                             {:data-lang mode}
+                             content'
+                             {}))
+
+       ;; wait for content load
+       (and format
+            (contains? (gp-config/text-formats) format))
+       (ui/loading "Loading ...")
 
        :else
        [:div (t :file/format-not-supported (name format))])]))
+
+(rum/defcs file
+  [state]
+  (let [path (get-path state)
+        format (gp-util/get-format path)]
+    (rum/with-key (file-inner path format) path)))

+ 4 - 4
src/main/frontend/components/header.cljs

@@ -236,10 +236,7 @@
                                         (mobile-util/native-iphone?))
                                 (state/set-left-sidebar-open! false))
                               (state/pub-event! [:go/search]))}
-              (ui/icon "search" {:size ui/icon-size})])))
-
-       (when (state/feature-http-server-enabled?)
-        (server/server-indicator (state/sub :electron/server)))]]
+              (ui/icon "search" {:size ui/icon-size})])))]]
 
      [:div.r.flex.drag-region
       (when (and current-repo
@@ -260,6 +257,9 @@
       (when config/lsp-enabled?
         (plugins/hook-ui-items :toolbar))
 
+      (when (state/feature-http-server-enabled?)
+        (server/server-indicator (state/sub :electron/server)))
+
       (when (util/electron?)
         (back-and-forward))
 

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

@@ -55,7 +55,7 @@
              (for [[idx page] (medley/indexed namespace)]
                (when (and (string? page) page)
                  (let [full-page (->> (take (inc idx) namespace)
-                                      (string/join "/"))]
+                                      util/string-join-path)]
                    (block/page-reference false
                                          full-page
                                          {}

+ 3 - 3
src/main/frontend/components/onboarding.cljs

@@ -38,11 +38,11 @@
                       [(t :help/changelog) "https://docs.logseq.com/#/page/changelog"]]}
 
           {:title "About"
-           :children [[(t :help/about) "https://logseq.com/blog/about"]]}
+           :children [[(t :help/about) "https://blog.logseq.com/about/"]]}
 
           {:title "Terms"
-           :children [[(t :help/privacy) "https://logseq.com/blog/privacy-policy"]
-                      [(t :help/terms) "https://logseq.com/blog/terms"]]}]]
+           :children [[(t :help/privacy) "https://blog.logseq.com/privacy-policy/"]
+                      [(t :help/terms) "https://blog.logseq.com/terms/"]]}]]
 
 
 

+ 10 - 11
src/main/frontend/components/page.cljs

@@ -15,7 +15,6 @@
             [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as model]
             [frontend.extensions.graph :as graph]
-            [frontend.extensions.pdf.assets :as pdf-assets]
             [frontend.extensions.pdf.utils :as pdf-utils]
             [frontend.format.block :as block]
             [frontend.handler.common :as common-handler]
@@ -296,7 +295,7 @@
           *edit? (get state ::edit?)
           *input-value (get state ::input-value)
           repo (state/get-current-repo)
-          hls-page? (pdf-assets/hls-file? title)
+          hls-page? (pdf-utils/hls-file? title)
           whiteboard-page? (model/whiteboard-page? page-name)
           untitled? (and whiteboard-page? (parse-uuid page-name)) ;; normal page cannot be untitled right?
           title (if hls-page?
@@ -474,15 +473,15 @@
             (reference/references route-page-name)
             (str route-page-name "-refs"))])
 
-       (when-not (or block? whiteboard?)
-         [:div
-          (when (not journal?)
-            (hierarchy/structures route-page-name))
+       (let [block-or-whiteboard? (or block? whiteboard?)]
+         (when-not block-or-whiteboard?
+           (when (not journal?)
+             (hierarchy/structures route-page-name)))
 
-          ;; TODO: or we can lazy load them
-          (when-not sidebar?
-            [:div {:key "page-unlinked-references"}
-             (reference/unlinked-references route-page-name)])])])))
+         (when-not block-or-whiteboard?
+           (when-not sidebar?
+             [:div {:key "page-unlinked-references"}
+              (reference/unlinked-references route-page-name)])))])))
 
 (defonce layout (atom [js/window.innerWidth js/window.innerHeight]))
 
@@ -561,7 +560,7 @@
               ;;         item))
               ;;     [{:label "gForce"}
               ;;      {:label "dagre"}])
-              ;;    (fn [value]
+              ;;    (fn [_e value]
               ;;      (set-setting! :layout value))
               ;;    "graph-layout")]
               [:div.flex.items-center.justify-between.mb-2

+ 2 - 4
src/main/frontend/components/page_menu.cljs

@@ -10,7 +10,6 @@
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
-            [frontend.util.url :as url-util]
             [frontend.util.page :as page-util]
             [frontend.handler.shell :as shell]
             [frontend.mobile.util :as mobile-util]
@@ -107,8 +106,7 @@
           (when (or (util/electron?)
                     (mobile-util/native-platform?))
             {:title   (t :page/copy-page-url)
-             :options {:on-click #(util/copy-to-clipboard!
-                                   (url-util/get-logseq-graph-page-url nil repo page-original-name))}})
+             :options {:on-click #(page-handler/copy-page-url page-original-name)}})
 
           (when-not contents?
             {:title   (t :page/delete)
@@ -137,7 +135,7 @@
             {:title   (t :export-page)
              :options {:on-click #(state/set-modal!
                                    (fn []
-                                     (export/export-blocks [(:block/uuid page)])))}})
+                                     (export/export-blocks (:block/name page))))}})
 
           (when (util/electron?)
             {:title   (t (if public? :page/make-private :page/make-public))

+ 2 - 1
src/main/frontend/components/plugins.cljs

@@ -391,7 +391,8 @@
                        {:label "Direct" :value "direct" :selected (= type "direct")}
                        {:label "HTTP"   :value "http"   :selected (= type "http")}
                        {:label "SOCKS5" :value "socks5" :selected (= type "socks5")}]
-                      #(set-opts! (assoc opts :type % :protocol %)))]]
+             (fn [_e value]
+               (set-opts! (assoc opts :type value :protocol value))))]]
       [:p.flex
        [:label.pr-4
         {:class (if disabled? "opacity-50" nil)}

+ 8 - 1
src/main/frontend/components/plugins.css

@@ -494,8 +494,15 @@
 
       .heading-item {
         margin: 12px 12px 6px;
-        font-weight: bold;
         border-bottom: 1px solid var(--ls-border-color, #738694);
+
+        h2 {
+          font-weight: bold;
+        }
+
+        small:empty {
+          display: none;
+        }
       }
 
       .desc-item {

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

@@ -65,7 +65,7 @@
          :radio (ui/radio-list options #(update-setting! key %) nil)
          :checkbox (ui/checkbox-list options #(update-setting! key %) nil)
          ;; select
-         (ui/select options #(update-setting! key %) nil))
+         (ui/select options (fn [_ value ] (update-setting! key value)) nil))
        ]]]))
 
 (rum/defc render-item-object
@@ -80,11 +80,12 @@
     [:div.pl-1 (edit-settings-file pid nil)]]])
 
 (rum/defc render-item-heading
-  [{:keys [key title]}]
+  [{:keys [key title description]}]
 
   [:div.heading-item
    {:data-key key}
-   [:h2 title]])
+   [:h2 title]
+   [:small description]])
 
 (rum/defc settings-container
   [schema ^js pl]

+ 463 - 0
src/main/frontend/components/query/builder.cljs

@@ -0,0 +1,463 @@
+(ns frontend.components.query.builder
+  "DSL query builder."
+  (:require [frontend.ui :as ui]
+            [frontend.date :as date]
+            [frontend.db :as db]
+            [frontend.db.model :as db-model]
+            [frontend.db.query-dsl :as query-dsl]
+            [frontend.handler.editor :as editor-handler]
+            [frontend.handler.query.builder :as query-builder]
+            [frontend.components.select :as component-select]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.search :as search]
+            [frontend.mixins :as mixins]
+            [logseq.db.default :as db-default]
+            [rum.core :as rum]
+            [clojure.string :as string]
+            [logseq.graph-parser.util :as gp-util]
+            [logseq.graph-parser.util.page-ref :as page-ref]))
+
+(rum/defc page-block-selector
+  [*find]
+  [:div.filter-item {:on-mouse-down (fn [e] (util/stop-propagation e))}
+   (ui/select [{:label "Blocks"
+                :value "block"
+                :selected (not= @*find :page)}
+               {:label "Pages"
+                :value "page"
+                :selected (= @*find :page)}]
+     (fn [e v]
+       ;; Prevent opening the current block's editor
+       (util/stop e)
+       (reset! *find (keyword v))))])
+
+(defn- select
+  ([items on-chosen]
+   (select items on-chosen {}))
+  ([items on-chosen options]
+   (component-select/select (merge
+                             {:items items
+                              :on-chosen on-chosen
+                              :extract-fn nil}
+                             options))))
+
+(defn append-tree!
+  [*tree {:keys [toggle-fn toggle?]
+          :or {toggle? true}} loc x]
+  (swap! *tree #(query-builder/append-element % loc x))
+  (when toggle? (toggle-fn)))
+
+(rum/defcs search < (rum/local nil ::input-value)
+  (mixins/event-mixin
+   (fn [state]
+     (mixins/on-key-down
+      state
+      {;; enter
+       13 (fn [state e]
+            (let [input-value (get state ::input-value)]
+              (when-not (string/blank? @input-value)
+                (util/stop e)
+                (let [on-submit (first (:rum/args state))]
+                  (on-submit @input-value))
+                (reset! input-value nil))))
+       ;; escape
+       27 (fn [_state _e]
+            (let [[_on-submit on-cancel] (:rum/args state)]
+              (on-cancel)))})))
+  [state _on-submit _on-cancel]
+  (let [*input-value (::input-value state)]
+    [:input#query-builder-search.form-input.block.sm:text-sm.sm:leading-5
+     {:auto-focus true
+      :placeholder "Full text search"
+      :aria-label "Full text search"
+      :on-change #(reset! *input-value (util/evalue %))}]))
+
+(defonce *shown-datepicker (atom nil))
+(defonce *between-dates (atom {}))
+(rum/defcs datepicker < rum/reactive
+  (rum/local nil ::input-value)
+  {:init (fn [state]
+           (when (:auto-focus (last (:rum/args state)))
+             (reset! *shown-datepicker (first (:rum/args state))))
+           state)
+   :will-unmount (fn [state]
+                   (swap! *between-dates dissoc (first (:rum/args state)))
+                   state)}
+  [state id placeholder {:keys [auto-focus]}]
+  (let [*input-value (::input-value state)
+        show? (= id (rum/react *shown-datepicker))]
+    [:div.ml-4
+     [:input.query-builder-datepicker.form-input.block.sm:text-sm.sm:leading-5
+      {:auto-focus (or auto-focus false)
+       :placeholder placeholder
+       :aria-label placeholder
+       :value @*input-value
+       :on-click #(reset! *shown-datepicker id)}]
+     (when show?
+       (ui/datepicker nil {:on-change (fn [_e date]
+                                        (let [journal-date (date/journal-name date)]
+                                          (reset! *input-value journal-date)
+                                          (reset! *shown-datepicker nil)
+                                          (swap! *between-dates assoc id journal-date)))}))]))
+
+(rum/defcs between <
+  (rum/local nil ::start)
+  (rum/local nil ::end)
+  [state {:keys [tree loc] :as opts}]
+  [:div.between-date {:on-mouse-down (fn [e] (util/stop-propagation e))}
+   [:div.flex.flex-row
+    [:div.font-medium.mt-2 "Between: "]
+    (datepicker :start "Start date" (merge opts {:auto-focus true}))
+    (datepicker :end "End date" opts)]
+   (ui/button "Submit"
+     :on-click (fn []
+                 (let [{:keys [start end]} @*between-dates]
+                   (when (and start end)
+                     (let [clause [:between start end]]
+                       (append-tree! tree opts loc clause)
+                       (reset! *between-dates {}))))))])
+
+(defn- query-filter-picker
+  [state *find *tree loc clause opts]
+  (let [*mode (::mode state)
+        *property (::property state)
+        repo (state/get-current-repo)]
+    [:div
+     (case @*mode
+       "namespace"
+       (let [items (sort (db-model/get-all-namespace-parents repo))]
+         (select items
+                 (fn [value]
+                   (append-tree! *tree opts loc [:namespace value]))))
+
+       "tags"
+       (let [items (->> (db-model/get-all-tagged-pages repo)
+                        (map second)
+                        sort)]
+         (select items
+                 (fn [value]
+                   (append-tree! *tree opts loc [:page-tags value]))))
+
+       "property"
+       (let [properties (search/get-all-properties)]
+         (select properties
+                 (fn [value]
+                   (reset! *mode "property-value")
+                   (reset! *property (keyword value)))))
+
+       "property-value"
+       (let [values (cons "Select all" (db-model/get-property-values @*property))]
+         (select values
+                 (fn [value]
+                   (let [x (if (= value "Select all")
+                             [(if (= @*find :page) :page-property :property) @*property]
+                             [(if (= @*find :page) :page-property :property) @*property value])]
+                     (reset! *property nil)
+                     (append-tree! *tree opts loc x)))))
+
+       "sample"
+       (select (range 1 101)
+               (fn [value]
+                 (append-tree! *tree opts loc [:sample (util/safe-parse-int value)])))
+
+       "task"
+       (select db-default/built-in-markers
+               (fn [value]
+                 (when (seq value)
+                   (append-tree! *tree opts loc (vec (cons :task value)))))
+               {:multiple-choices? true
+                ;; Need the existing choices later to improve the UX
+                :selected-choices #{}
+                :prompt-key :select/default-select-multiple
+                :close-modal? false})
+
+       "priority"
+       (select db-default/built-in-priorities
+               (fn [value]
+                 (when (seq value)
+                   (append-tree! *tree opts loc (vec (cons :priority value)))))
+               {:multiple-choices? true
+                :selected-choices #{}
+                :prompt-key :select/default-select-multiple
+                :close-modal? false})
+
+       "page"
+       (let [pages (sort (db-model/get-all-page-original-names repo))]
+         (select pages
+                 (fn [value]
+                   (append-tree! *tree opts loc [:page value]))))
+
+       "page reference"
+       (let [pages (sort (db-model/get-all-page-original-names repo))]
+         (select pages
+                 (fn [value]
+                   (append-tree! *tree opts loc [:page-ref value]))
+                 {}))
+
+       "full text search"
+       (search (fn [v] (append-tree! *tree opts loc v))
+               (:toggle-fn opts))
+
+       "between"
+       (between (merge opts
+                       {:tree *tree
+                        :loc loc
+                        :clause clause}))
+
+       nil)]))
+
+(rum/defcs picker <
+  {:will-mount (fn [state]
+                 (state/clear-selection!)
+                 state)}
+  (rum/local nil ::mode)                ; pick mode
+  (rum/local nil ::property)
+  [state *find *tree loc clause opts]
+  (let [*mode (::mode state)
+        filters (if (= :page @*find)
+                  query-builder/page-filters
+                  query-builder/block-filters)
+        filters-and-ops (concat filters query-builder/operators)
+        operator? #(contains? query-builder/operators-set (keyword %))]
+    [:div.query-builder-picker
+     (if @*mode
+       (when-not (operator? @*mode)
+         (query-filter-picker state *find *tree loc clause opts))
+       [:div
+        (when-not @*find
+          [:div.flex.flex-row.items-center.p-2.justify-between
+           [:div.ml-2 "Find: "]
+           (page-block-selector *find)])
+        (when-not @*find
+          [:hr.m-0])
+        (select
+          (map name filters-and-ops)
+          (fn [value]
+            (cond
+              (= value "all page tags")
+              (append-tree! *tree opts loc [:all-page-tags])
+
+              (operator? value)
+              (append-tree! *tree opts loc [(keyword value)])
+
+              :else
+              (reset! *mode value)))
+          {:input-default-placeholder "Add filter/operator"})])]))
+
+(rum/defc add-filter
+  [*find *tree loc clause]
+  (ui/dropdown
+   (fn [{:keys [toggle-fn]}]
+     [:a.flex.add-filter {:title "Add clause"
+                          :on-click toggle-fn}
+      (ui/icon "plus" {:style {:font-size 20}})])
+   (fn [{:keys [toggle-fn]}]
+     (picker *find *tree loc clause {:toggle-fn toggle-fn}))
+   {:modal-class (util/hiccup->class
+                  "origin-top-right.absolute.left-0.mt-2.ml-2.rounded-md.shadow-lg")}))
+
+(declare clauses-group)
+
+(defn- dsl-human-output
+  [clause]
+  (let [f (first clause)]
+    (cond
+      (string? clause)
+      (str "search: " clause)
+
+      (= (keyword f) :page-ref)
+      (page-ref/->page-ref (second clause))
+
+      (= (keyword f) :page-tags)
+      (if (string? (second clause))
+        (str "#" (second clause))
+        (str "#" (second (second clause))))
+
+      (contains? #{:property :page-property} (keyword f))
+      (str (name (second clause)) ": "
+           (cond
+             (and (vector? (last clause)) (= :page-ref (first (last clause))))
+             (second (last clause))
+
+             (= 2 (count clause))
+             "ALL"
+
+             :else
+             (last clause)))
+
+      (= (keyword f) :between)
+      (str "between: " (second (second clause)) " - " (second (last clause)))
+
+      (contains? #{:task :priority} (keyword f))
+      (str (name f) ": "
+           (string/join " | " (rest clause)))
+
+      (contains? #{:page :task :namespace} (keyword f))
+      (str (name f) ": " (if (vector? (second clause))
+                           (second (second clause))
+                           (second clause)))
+
+      (= 2 (count clause))
+      (str (name f) ": " (second clause))
+
+      :else
+      (str (query-builder/->dsl clause)))))
+
+(rum/defc clause-inner
+  [*tree loc clause & {:keys [operator?]}]
+  (ui/dropdown
+   (fn [{:keys [toggle-fn]}]
+     (if operator?
+       [:a.flex.text-sm.query-clause {:on-click toggle-fn}
+        clause]
+
+       [:div.flex.flex-row.items-center.gap-2.p-1.rounded.border
+        [:a.flex.query-clause {:on-click toggle-fn}
+         (dsl-human-output clause)]]))
+   (fn [{:keys [toggle-fn]}]
+     [:div.p-4.flex.flex-col.gap-2
+      [:a {:title "Delete"
+           :on-click (fn []
+                       (swap! *tree (fn [q]
+                                      (let [loc' (if operator? (vec (butlast loc)) loc)]
+                                        (query-builder/remove-element q loc'))))
+                       (toggle-fn))}
+       "Delete"]
+
+      (when operator?
+        [:a {:title "Unwrap this operator"
+             :on-click (fn []
+                         (swap! *tree (fn [q]
+                                        (let [loc' (vec (butlast loc))]
+                                          (query-builder/unwrap-operator q loc'))))
+                         (toggle-fn))}
+         "Unwrap"])
+
+      [:div.font-medium.text-sm "Wrap this filter with: "]
+      [:div.flex.flex-row.gap-2
+       (for [op query-builder/operators]
+         (ui/button (string/upper-case (name op))
+           :intent "logseq"
+           :small? true
+           :on-click (fn []
+                       (swap! *tree (fn [q]
+                                      (let [loc' (if operator? (vec (butlast loc)) loc)]
+                                        (query-builder/wrap-operator q loc' op))))
+                       (toggle-fn))))]
+
+      (when operator?
+        [:div
+         [:div.font-medium.text-sm "Replace with: "]
+         [:div.flex.flex-row.gap-2
+          (for [op (remove #{(keyword (string/lower-case clause))} query-builder/operators)]
+            (ui/button (string/upper-case (name op))
+              :intent "logseq"
+              :small? true
+              :on-click (fn []
+                          (swap! *tree (fn [q]
+                                         (query-builder/replace-element q loc op)))
+                          (toggle-fn))))]])])
+   {:modal-class (util/hiccup->class
+                  "origin-top-right.absolute.left-0.mt-2.ml-2.rounded-md.shadow-lg.w-64")}))
+
+(rum/defc clause
+  [*tree *find loc clause]
+  (when (seq clause)
+    [:div.query-builder-clause
+     (let [kind (keyword (first clause))]
+       (if (query-builder/operators-set kind)
+         [:div.operator-clause.flex.flex-row.items-center {:data-level (count loc)}
+          [:div.text-4xl.mr-1.font-thin "("]
+          (clauses-group *tree *find (conj loc 0) kind (rest clause))
+          [:div.text-4xl.ml-1.font-thin ")"]]
+         (clause-inner *tree loc clause)))]))
+
+(rum/defc clauses-group
+  [*tree *find loc kind clauses]
+  (let [parens? (and (= loc [0])
+                     (> (count clauses) 1))]
+    [:div.clauses-group
+     (when parens? [:div.text-4xl.mr-1.font-thin "("])
+     (when-not (and (= loc [0])
+                    (= kind :and)
+                    (<= (count clauses) 1))
+       (clause-inner *tree loc
+                     (string/upper-case (name kind))
+                     :operator? true))
+
+     (map-indexed (fn [i item]
+                    (clause *tree *find (update loc (dec (count loc)) #(+ % i 1)) item))
+                  clauses)
+
+     (when parens? [:div.text-4xl.ml-1.font-thin ")"])
+
+     (when (not= loc [0])
+       (add-filter *find *tree loc []))]))
+
+(rum/defc clause-tree < rum/reactive
+  [*tree *find]
+  (let [tree (rum/react *tree)
+        kind ((set query-builder/operators) (first tree))
+        [kind' clauses] (if kind
+                          [kind (rest tree)]
+                          [:and [@tree]])]
+    (clauses-group *tree *find [0] kind' clauses)))
+
+(rum/defcs builder <
+  (rum/local nil ::find)
+  {:init (fn [state]
+           (let [q-str (first (:rum/args state))
+                 query (gp-util/safe-read-string
+                        query-dsl/custom-readers
+                        (query-dsl/pre-transform-query q-str))
+                 query' (cond
+                          (contains? #{'and 'or 'not} (first query))
+                          query
+
+                          query
+                          [:and query]
+
+                          :else
+                          [:and])
+                 tree (query-builder/from-dsl query')
+                 *tree (atom tree)
+                 config (last (:rum/args state))]
+             (add-watch *tree :updated (fn [_ _ _old _new]
+                                         (when-let [block (:block config)]
+                                           (let [q (if (= [:and] @*tree)
+                                                     ""
+                                                     (let [result (query-builder/->dsl @*tree)]
+                                                       (if (string? result)
+                                                         (util/format "\"%s\"" result)
+                                                         (str result))))
+                                                 repo (state/get-current-repo)
+                                                 block (db/pull [:block/uuid (:block/uuid block)])]
+                                             (when block
+                                               (let [content (string/replace (:block/content block)
+                                                                             (util/format "{{query %s" q-str)
+                                                                             (util/format "{{query %s" q))]
+                                                 (editor-handler/save-block! repo (:block/uuid block) content)))))))
+             (assoc state ::tree *tree)))
+   :will-mount (fn [state]
+                 (let [q-str (first (:rum/args state))
+                       parsed-query (query-dsl/parse-query q-str)
+                       blocks-query? (:blocks? parsed-query)
+                       find-mode (cond
+                                   blocks-query?
+                                   :block
+                                   (false? blocks-query?)
+                                   :page
+                                   :else
+                                   nil)]
+                   (when find-mode (reset! (::find state) find-mode))
+                   state))}
+  [state _query _config]
+  (let [*find (::find state)
+        *tree (::tree state)]
+    [:div.cp__query-builder
+     [:div.cp__query-builder-filter
+      (when (and (seq @*tree)
+                 (not= @*tree [:and]))
+        (clause-tree *tree *find))
+      (add-filter *find *tree [0] [])]]))

+ 46 - 0
src/main/frontend/components/query/builder.css

@@ -0,0 +1,46 @@
+.cp__query-builder {
+    @apply grid auto-rows-max gap-2;
+
+    &-filter {
+        @apply flex flex-row items-center gap-1;
+    }
+
+    .cp__select-main {
+        width: fit-content;
+        margin: 0;
+    }
+
+    .between-date {
+        min-width: 36em;
+        padding: 1em;
+    }
+
+    .cp__select .input-wrap {
+        height: auto;
+        min-width: 14em;
+    }
+
+    .cp__select .input-wrap input {
+        border: none;
+    }
+
+    .cp__select-input {
+        padding: 0.5em 1em;
+    }
+
+    .clauses-group {
+        @apply flex flex-row gap-1 flex-wrap items-center text-sm;
+    }
+
+    a.query-clause, a.add-filter {
+        color: var(--ls-primary-text-color);
+    }
+
+    a.query-clause:hover, a.add-filter {
+        color: var(--ls-secondary-text-color);
+    }
+
+    .filter-item select {
+        border: none;
+    }
+}

+ 9 - 3
src/main/frontend/components/query_table.cljs

@@ -100,6 +100,7 @@
 (defn- get-columns [current-block result {:keys [page?]}]
   (let [query-properties (some-> (get-in current-block [:block/properties :query-properties] "")
                                  (common-handler/safe-read-string "Parsing query properties failed"))
+        query-properties (if page? (remove #{:block} query-properties) query-properties)
         columns (if (seq query-properties)
                   query-properties
                   (get-keys result page?))
@@ -114,10 +115,12 @@
 ;; Table rows are called items
 (rum/defcs result-table < rum/reactive
   (rum/local false ::select?)
+  (rum/local false ::mouse-down?)
   [state config current-block result {:keys [page?]} map-inline page-cp ->elem inline-text]
   (when current-block
     (let [result (tree/filter-top-level-blocks result)
           select? (get state ::select?)
+          *mouse-down? (::mouse-down? state)
           ;; remove templates
           result (remove (fn [b] (some? (get-in b [:block/properties :template]))) result)
           result (if page? result (attach-clock-property result))
@@ -173,14 +176,17 @@
                               [:string (or (get-in item [:block/properties-text-values column])
                                            ;; Fallback to property relationships for page blocks
                                            (get-in item [:block/properties column]))])]
-                  [:td.whitespace-nowrap {:on-mouse-down (fn [] (reset! select? false))
+                  [:td.whitespace-nowrap {:on-mouse-down (fn []
+                                                           (reset! *mouse-down? true)
+                                                           (reset! select? false))
                                           :on-mouse-move (fn [] (reset! select? true))
                                           :on-mouse-up (fn []
-                                                         (when-not @select?
+                                                         (when (and @*mouse-down? (not @select?))
                                                            (state/sidebar-add-block!
                                                             (state/get-current-repo)
                                                             (:db/id item)
-                                                            :block-ref)))}
+                                                            :block-ref)
+                                                           (reset! *mouse-down? false)))}
                    (when value
                      (if (= :element (first value))
                        (second value)

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません