Pārlūkot izejas kodu

Merge branch 'master' into enhance/properties

Confirmed clj-kondo and unit tests pass
Gabriel Horner 2 gadi atpakaļ
vecāks
revīzija
1e18e3b26e
100 mainītis faili ar 3032 papildinājumiem un 1018 dzēšanām
  1. 3 0
      .clj-kondo/config.edn
  2. 9 18
      .github/ISSUE_TEMPLATE/bug_report.yaml
  3. 5 5
      .github/workflows/build-android.yml
  4. 1 1
      .github/workflows/build-desktop-release.yml
  5. 83 0
      .github/workflows/build-ios-release.yml
  6. 2 0
      .github/workflows/build-ios.yml
  7. 11 11
      .github/workflows/build-stage.yml
  8. 30 25
      .github/workflows/build.yml
  9. 1 1
      .github/workflows/db.yml
  10. 9 8
      .github/workflows/e2e.yml
  11. 5 13
      .github/workflows/graph-parser.yml
  12. 4 1
      .gitignore
  13. 9 2
      README.md
  14. 2 2
      android/app/build.gradle
  15. 1 0
      android/app/capacitor.build.gradle
  16. 4 0
      android/app/src/main/assets/capacitor.plugins.json
  17. 158 119
      android/app/src/main/java/com/logseq/app/FsWatcher.java
  18. 2 0
      android/app/src/main/res/values/colors.xml
  19. 2 0
      android/app/src/main/res/values/styles.xml
  20. 3 0
      android/capacitor.settings.gradle
  21. 6 6
      bb.edn
  22. 8 1
      capacitor.config.ts
  23. 3 6
      deps.edn
  24. 2 2
      deps/db/bb.edn
  25. 1 1
      deps/db/deps.edn
  26. 56 55
      deps/db/src/logseq/db/rules.cljc
  27. 2 0
      deps/graph-parser/.carve/ignore
  28. 1 1
      deps/graph-parser/bb.edn
  29. 1 1
      deps/graph-parser/deps.edn
  30. 74 9
      deps/graph-parser/src/logseq/graph_parser.cljs
  31. 6 3
      deps/graph-parser/src/logseq/graph_parser/cli.cljs
  32. 6 6
      deps/graph-parser/src/logseq/graph_parser/text.cljs
  33. 0 1
      deps/graph-parser/src/logseq/graph_parser/util.cljs
  34. 16 7
      deps/graph-parser/src/logseq/graph_parser/whiteboard.cljs
  35. 1 1
      deps/graph-parser/test/logseq/graph_parser_test.cljs
  36. 12 0
      docs/dev-practices.md
  37. 1 1
      e2e-tests/basic.spec.ts
  38. 0 2
      e2e-tests/code-editing.spec.ts
  39. 52 30
      e2e-tests/editor.spec.ts
  40. 28 2
      e2e-tests/fixtures.ts
  41. 1 1
      e2e-tests/logseq-url.spec.ts
  42. 2 1
      e2e-tests/page-rename.spec.ts
  43. 102 64
      e2e-tests/page-search.spec.ts
  44. 3 4
      e2e-tests/random.spec.ts
  45. 9 0
      e2e-tests/sanitization.spec.ts
  46. 3 2
      e2e-tests/sidebar.spec.ts
  47. 6 6
      e2e-tests/util/keyboard-events.ts
  48. 8 4
      e2e-tests/utils.ts
  49. 126 103
      e2e-tests/whiteboards.spec.ts
  50. 4 4
      ios/App/App.xcodeproj/project.pbxproj
  51. 14 5
      ios/App/App/FsWatcher.swift
  52. 2 0
      ios/App/App/Info.plist
  53. 1 0
      ios/App/Podfile
  54. 8 0
      ios/App/fastlane/Appfile
  55. 53 0
      ios/App/fastlane/Fastfile
  56. 13 0
      ios/App/fastlane/Matchfile
  57. 32 0
      ios/App/fastlane/README.md
  58. 1 0
      libs/.npmignore
  59. 30 0
      libs/CHANGELOG.md
  60. 10 0
      libs/babel.config.json
  61. 9 2
      libs/package.json
  62. 1 0
      libs/src/LSPlugin.caller.ts
  63. 13 4
      libs/src/LSPlugin.core.ts
  64. 45 3
      libs/src/LSPlugin.ts
  65. 25 3
      libs/src/LSPlugin.user.ts
  66. 1 1
      libs/src/modules/LSPlugin.Experiments.ts
  67. 82 0
      libs/src/modules/LSPlugin.Search.ts
  68. 16 2
      libs/webpack.config.js
  69. 956 4
      libs/yarn.lock
  70. 7 14
      package.json
  71. 0 12
      public/index.html
  72. 26 20
      resources/css/common.css
  73. 52 20
      resources/css/tabler-extension.css
  74. 0 1
      resources/electron.html
  75. BIN
      resources/fonts/tabler-icons-extension.woff2
  76. BIN
      resources/img/whiteboard-welcome-dark.png
  77. BIN
      resources/img/whiteboard-welcome-light.png
  78. 0 1
      resources/index.html
  79. 0 0
      resources/js/lsplugin.core.js
  80. 0 0
      resources/js/lsplugin.user.js
  81. 9 0
      resources/js/swiped-events.min.js
  82. 5 6
      resources/package.json
  83. 29 0
      scripts/build-ios.sh
  84. 1 1
      scripts/get-pkg-version.js
  85. 47 0
      scripts/patch-xcode-project.sh
  86. 15 0
      scripts/src/logseq/tasks/malli.clj
  87. 6 11
      shadow-cljs.edn
  88. 19 6
      src/dev-cljs/shadow/hooks.clj
  89. 1 1
      src/electron/electron/core.cljs
  90. 1 1
      src/electron/electron/fs_watcher.cljs
  91. 23 4
      src/electron/electron/handler.cljs
  92. 1 1
      src/electron/electron/plugin.cljs
  93. 211 46
      src/electron/electron/search.cljs
  94. 12 2
      src/electron/electron/url.cljs
  95. 64 26
      src/main/electron/listener.cljs
  96. 9 8
      src/main/frontend/commands.cljs
  97. 178 154
      src/main/frontend/components/block.cljs
  98. 80 71
      src/main/frontend/components/block.css
  99. 28 4
      src/main/frontend/components/command_palette.css
  100. 12 54
      src/main/frontend/components/content.cljs

+ 3 - 0
.clj-kondo/config.edn

@@ -11,6 +11,8 @@
 
  :linters
  {: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
                                 goog.string.unescapeEntities
                                 ;; TODO:lint: Fix when fixing all type hints
@@ -42,6 +44,7 @@
              frontend.db.query-react query-react
              frontend.diff diff
              frontend.encrypt encrypt
+             frontend.extensions.sci sci
              frontend.format.mldoc mldoc
              frontend.format.block block
              frontend.fs fs

+ 9 - 18
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -4,7 +4,7 @@ body:
   - type: textarea
     id: problem
     attributes:
-      label: What happened?
+      label: What Happened?
       description: |
         Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner.
     validations:
@@ -31,33 +31,24 @@ body:
     validations:
       required: false
   - type: textarea
-    id: Screenshots
+    id: screenshots
     attributes:
       label: Screenshots
       description: |
-        If applicable, add screenshots to help explain your problem.
+        If applicable, add screenshots or screen recordings to help explain your problem.
     validations:
       required: false
   - type: textarea
-    id: desktop
+    id: platform
     attributes:
-      label: Desktop Platform Information
+      label: Desktop or Mobile Platform Information
       description: |
-        Would you mind to tell us the system information about your desktop platform?
+        Would you mind to tell us the system information about your desktop or mobile platform?
       placeholder: |
         OS version, Browser or App, Logseq App version
-        example: MacOS 12.2, App, v0.5.9
-    validations:
-      required: false
-  - type: textarea
-    id: mobile
-    attributes:
-      label: Mobile Platform Information
-      description: |
-        Would you mind to tell us the system information about your mobile platform?
-      placeholder: |
-        Device, OS version, Browser or App, Logseq App version
-        example: iPhone6, iOS8.1, App, v0.5.9
+        example: macOS 12.2, Desktop App v0.5.9
+        example: iPhone 12, iOS8.1, v0.5.9
+        example: Pixel XL, Android 12, v0.5.9
     validations:
       required: false
   - type: textarea

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

@@ -98,17 +98,17 @@ jobs:
           echo ::set-output name=version::$pkgver
 
       - name: Update Nightly APP Version
-        if: ${{ inputs.build-target == 'nightly' || github.event.inputs.build-target == 'nightly' }}
+        if: ${{ inputs.build-target == '' || inputs.build-target == 'nightly' || github.event.inputs.build-target == 'nightly' }}
         run: |
           sed -i 's/defonce version ".*"/defonce version "${{ steps.ref.outputs.version }}"/g' src/main/frontend/version.cljs
           sed -i 's/versionName ".*"/versionName "${{ steps.ref.outputs.version }}"/g' android/app/build.gradle
 
       - name: Set Build Environment Variables
         run: |
-          echo "ENABLE_FILE_SYNC_PRODUCTION=${{ inputs.enable-file-sync-production == 'true' || github.event.inputs.enable-file-sync-production == 'true' }}" >> $GITHUB_ENV
+          echo "ENABLE_FILE_SYNC_PRODUCTION=${{ inputs.enable-file-sync-production || github.event.inputs.enable-file-sync-production || inputs.build-target == '' }}" >> $GITHUB_ENV
 
-      - name: Compile CLJS - android variant, use es6 instead of es-next
-        run: yarn install && yarn release-android-app
+      - name: Compile CLJS - app variant, use es6 instead of es-next
+        run: yarn install && yarn release-app
 
       - name: Upload Sentry Sourcemaps (beta only)
         if: ${{ github.repository == 'logseq/logseq' && (inputs.build-target == 'beta' || github.event.inputs.build-target == 'beta') }}
@@ -133,7 +133,7 @@ jobs:
           rm -rvf android/app/src/main/assets/public || true
 
       - name: Sync public to Android Project
-        run: npx cap sync
+        run: npx cap sync android
 
       - name: Setup Android SDK
         uses: android-actions/setup-android@v2

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

@@ -425,7 +425,7 @@ jobs:
     with:
       build-target: "${{ github.event.inputs.build-target }}"
       # if scheduled, use production mode
-      enable-file-sync-production: true
+      enable-file-sync-production: "${{ github.event_name == 'schedule' || github.event.inputs.enable-file-sync-production == 'true' }}"
     secrets:
       ANDROID_KEYSTORE: "${{ secrets.ANDROID_KEYSTORE }}"
       ANDROID_KEYSTORE_PASSWORD: "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}"

+ 83 - 0
.github/workflows/build-ios-release.yml

@@ -0,0 +1,83 @@
+# This workflow build iOS App as a .ipa file and upload it to TestFlight.
+
+name: Build-iOS
+
+on:
+  workflow_dispatch:
+    inputs:
+      git-ref:
+        description: "Release Git Ref (Which branch or tag to build?)"
+        required: true
+        default: "master"
+
+env:
+  CLOJURE_VERSION: '1.10.1.763'
+  NODE_VERSION: '16'
+  JAVA_VERSION: '11'
+
+jobs:
+  build-app:
+    runs-on: macos-latest
+    steps:
+      - name: Check out Git repository
+        uses: actions/checkout@v3
+        with:
+          ref: ${{ github.event.inputs.git-ref }}
+
+      - name: Install Node.js, NPM and Yarn
+        uses: actions/setup-node@v3
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+
+      - name: Setup Java JDK
+        uses: actions/setup-java@v3
+        with:
+          distribution: 'zulu'
+          java-version: ${{ env.JAVA_VERSION }}
+
+      - name: Cache clojure deps
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/.m2/repository
+            ~/.gitlibs
+          key: ${{ runner.os }}-clojure-lib-${{ hashFiles('**/deps.edn') }}
+
+      - name: Setup clojure
+        uses: DeLaGuardo/[email protected]
+        with:
+          cli: ${{ env.CLOJURE_VERSION }}
+
+      - name: Setup build tools
+        run: brew install fastlane
+
+      - name: Set Build Environment Variables
+        run: |
+          echo "ENABLE_FILE_SYNC_PRODUCTION=true" >> $GITHUB_ENV
+
+      - name: Compile CLJS
+        run: yarn install && yarn release-app
+
+      - name: Sync static build files
+        run: rsync -avz --exclude node_modules --exclude '*.js.map' --exclude android ./static/ ./public/static/
+
+      - name: Prepare iOS build
+        run: npx cap sync ios
+
+      - name: Build and upload iOS app
+        run: fastlane beta
+        working-directory: ./ios/App
+        env:
+          APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }}
+          APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
+          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}
+          APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: true
+          SLACK_URL: ${{ secrets.SLACK_URL }}
+          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
+          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
+
+      - name: Save Static File
+        uses: actions/upload-artifact@v3
+        with:
+          name: static
+          path: static

+ 2 - 0
.github/workflows/build-ios.yml

@@ -8,10 +8,12 @@ on:
     branches: [master]
     paths:
       - 'ios/App'
+      - package.json
   pull_request:
     branches: [master]
     paths:
       - 'ios/App'
+      - package.json
 
 env:
   CLOJURE_VERSION: '1.10.1.763'

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

@@ -37,16 +37,16 @@ jobs:
         run: yarn cache clean && yarn install --frozen-lockfile
 
       - name: Build Released-Web
-        run: yarn gulp:build && clojure -M:cljs release app  --config-merge '{:asset-path "${{env.asset-path}}"}'
+        run: |
+          yarn gulp:build && clojure -M:cljs release app  --config-merge '{:asset-path "${{env.asset-path}}" :compiler-options {:source-map-include-sources-content false :source-map-detail-level :symbols}}'
+          ls -ah ./static/js
 
-      - uses: jakejarvis/s3-sync-action@master
+      - name: Publish to Cloudflare Pages
+        uses: cloudflare/pages-action@1
         with:
-            #args: --acl public-read --follow-symlinks --delete
-            args: --acl public-read --follow-symlinks
-        env:
-          AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
-          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-          AWS_REGION: 'us-west-1'   # optional: defaults to us-east-1
-          SOURCE_DIR: 'static'      # optional: defaults to entire repository
-          DEST_DIR: ${GITHUB_REF##*/}/static
+          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          accountId: 2553ea8236c11ea0f88de28fce1cbfee
+          projectName: 'logseq-demo'
+          directory: 'static'
+          gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+          branch: 'production'

+ 30 - 25
.github/workflows/build.yml

@@ -17,7 +17,7 @@ env:
   JAVA_VERSION: '8'
   # This is the latest node version we can run.
   NODE_VERSION: '16'
-  BABASHKA_VERSION: '0.8.1'
+  BABASHKA_VERSION: '1.0.168'
 
 jobs:
 
@@ -30,10 +30,10 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Set up Node
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
           cache: 'yarn'
@@ -42,18 +42,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: |
@@ -79,23 +79,19 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - 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: Setup Babashka
-        uses: turtlequeue/[email protected]
-        with:
-          babashka-version: ${{ env.BABASHKA_VERSION }}
+          bb: ${{ env.BABASHKA_VERSION }}
 
       - name: Run clj-kondo lint
         run: clojure -M:clj-kondo --parallel --lint src
@@ -117,10 +113,10 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v2
+        uses: actions/checkout@v3
 
       - name: Set up Node
-        uses: actions/setup-node@v2
+        uses: actions/setup-node@v3
         with:
           node-version: ${{ env.NODE_VERSION }}
           cache: 'yarn'
@@ -129,13 +125,13 @@ 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 }}
 
@@ -154,7 +150,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
@@ -172,21 +168,30 @@ jobs:
       - name: Prepare E2E test build
         run: |
           yarn gulp:build && clojure -M:cljs compile app publishing electron
-          (cd static && yarn install && yarn rebuild:better-sqlite3)
+          (cd static && yarn install && yarn rebuild:all)
 
       # Exits with 0 if yarn.lock is up to date or 1 if we forgot to update it
       - name: Ensure static yarn.lock is up to date
         run: git diff --exit-code static/yarn.lock
 
-      - name: Run Playwright test
-        run: xvfb-run -- yarn e2e-test
+      - name: Run Playwright test - 1/2
+        run: xvfb-run -- npx playwright test --reporter github --shard=1/2
+        env:
+          LOGSEQ_CI: true
+          DEBUG: "pw:api"
+          RELEASE: true # skip dev only test
+
+      - name: Run Playwright test - 2/2
+        run: xvfb-run -- npx playwright test --reporter github --shard=2/2
         env:
-          CI: true
+          LOGSEQ_CI: true
           DEBUG: "pw:api"
+          RELEASE: true # skip dev only test
 
       - name: Save test artifacts
         if: ${{ failure() }}
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: e2e-test-report
-          path: artifacts.zip
+          path: e2e-dump/*
+          retention-days: 1

+ 1 - 1
.github/workflows/db.yml

@@ -25,7 +25,7 @@ env:
   JAVA_VERSION: '8'
   # This is the latest node version we can run.
   NODE_VERSION: '16'
-  BABASHKA_VERSION: '0.8.156'
+  BABASHKA_VERSION: '1.0.168'
 
 jobs:
   test:

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

@@ -21,7 +21,7 @@ env:
   JAVA_VERSION: '8'
   # This is the latest node version we can run.
   NODE_VERSION: '16'
-  BABASHKA_VERSION: '0.8.1'
+  BABASHKA_VERSION: '1.0.168'
 
 jobs:
   e2e-test-build:
@@ -142,13 +142,14 @@ jobs:
       - name: Run Playwright test
         run: xvfb-run -- npx playwright test --reporter github --shard=${{ matrix.shard }}/3
         env:
-          CI: true
+          LOGSEQ_CI: true
           DEBUG: "pw:api"
           RELEASE: true # skip dev only test
 
-      # - name: Save test artifacts
-      #   if: ${{ failure() }}
-      #   uses: actions/upload-artifact@v2
-      #   with:
-      #     name: e2e-test-report
-      #     path: artifacts.zip
+      - name: Save e2e artifacts
+        if: ${{ failure() }}
+        uses: actions/upload-artifact@v3
+        with:
+          name: e2e-repeat-report-${{ matrix.shard}}-${{ matrix.repeat }}
+          path: e2e-dump/*
+          retention-days: 1

+ 5 - 13
.github/workflows/graph-parser.yml

@@ -29,7 +29,7 @@ env:
   JAVA_VERSION: '8'
   # This is the latest node version we can run.
   NODE_VERSION: '16'
-  BABASHKA_VERSION: '0.8.156'
+  BABASHKA_VERSION: '1.0.168'
 
 jobs:
   test:
@@ -53,14 +53,10 @@ 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 }}
-
-      - name: Setup Babashka
-        uses: turtlequeue/[email protected]
-        with:
-          babashka-version: ${{ env.BABASHKA_VERSION }}
+          bb: ${{ env.BABASHKA_VERSION }}
 
       - name: Clojure cache
         uses: actions/cache@v3
@@ -103,14 +99,10 @@ 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 }}
-
-      - name: Setup Babashka
-        uses: turtlequeue/[email protected]
-        with:
-          babashka-version: ${{ env.BABASHKA_VERSION }}
+          bb: ${{ env.BABASHKA_VERSION }}
 
       - name: Run clj-kondo lint
         run: clojure -M:clj-kondo --parallel --lint src test

+ 4 - 1
.gitignore

@@ -1,3 +1,4 @@
+/e2e-dump
 /target
 /classes
 /checkouts
@@ -12,7 +13,8 @@ pom.xml.asc
 .hg/
 
 node_modules/
-static/
+static/**
+!static/yarn.lock
 tmp
 cljs-test-runner-out
 
@@ -52,3 +54,4 @@ ios/App/App/capacitor.config.json
 android/app/src/main/assets/capacitor.config.json
 
 *.sublime-*
+/public/static

+ 9 - 2
README.md

@@ -67,7 +67,7 @@ Logseq is also made possible by the following projects:
 
 ## Learn more
 
-- Our blog: https://logseq.com/blog - Please be sure to visit our [About page](https://logseq.com/blog/about) for the latest updates of the app
+- 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
@@ -83,10 +83,17 @@ We have [a dedicated overview page](https://github.com/logseq/logseq/blob/master
 ## 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).
 
-* For Windows users, please refer to [Develop LogSeq on Windows](docs/develop-logseq-on-windows.md) in addition.
+* For Windows users, please refer to [Develop Logseq on Windows](docs/develop-logseq-on-windows.md) in addition.
 
 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)
 
+## 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.
+
+Once you push your code to your fork you we'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)
+
+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
 
 [![JetBrains](docs/assets/jetbrains.svg)](https://www.jetbrains.com/?from=logseq)

+ 2 - 2
android/app/build.gradle

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

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

@@ -19,6 +19,7 @@ dependencies {
     implementation project(':capacitor-splash-screen')
     implementation project(':capacitor-status-bar')
     implementation project(':capawesome-capacitor-background-task')
+    implementation project(':hugotomazi-capacitor-navigation-bar')
     implementation project(':logseq-capacitor-file-sync')
     implementation project(':capacitor-voice-recorder')
     implementation project(':send-intent')

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

@@ -39,6 +39,10 @@
 		"pkg": "@capawesome/capacitor-background-task",
 		"classpath": "io.capawesome.capacitorjs.plugins.backgroundtask.BackgroundTaskPlugin"
 	},
+	{
+		"pkg": "@hugotomazi/capacitor-navigation-bar",
+		"classpath": "br.com.tombus.capacitor.plugin.navigationbar.NavigationBarPlugin"
+	},
 	{
 		"pkg": "@logseq/capacitor-file-sync",
 		"classpath": "com.logseq.app.filesync.FileSyncPlugin"

+ 158 - 119
android/app/src/main/java/com/logseq/app/FsWatcher.java

@@ -1,7 +1,5 @@
 package com.logseq.app;
 
-import android.annotation.SuppressLint;
-import android.os.Build;
 import android.system.ErrnoException;
 import android.system.Os;
 import android.system.StructStat;
@@ -12,8 +10,9 @@ import android.net.Uri;
 
 import java.io.*;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Stack;
 import java.util.regex.Pattern;
 
 import java.io.File;
@@ -26,9 +25,9 @@ import com.getcapacitor.PluginCall;
 
 @CapacitorPlugin(name = "FsWatcher")
 public class FsWatcher extends Plugin {
-
-    List<SingleFileObserver> observers;
     private String mPath;
+    private PollingFsWatcher mWatcher;
+    private Thread mThread;
 
     @Override
     public void load() {
@@ -37,14 +36,11 @@ public class FsWatcher extends Plugin {
 
     @PluginMethod()
     public void watch(PluginCall call) {
-        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
-            call.reject("Android version not supported");
-            return;
-        }
         String pathParam = call.getString("path");
         // check file:// or no scheme uris
         Uri u = Uri.parse(pathParam);
         Log.i("FsWatcher", "watching " + u);
+        // TODO: handle context:// uri
         if (u.getScheme() == null || u.getScheme().equals("file")) {
             File pathObj;
             try {
@@ -56,32 +52,15 @@ public class FsWatcher extends Plugin {
 
             mPath = pathObj.getAbsolutePath();
 
-            int mask = FileObserver.CLOSE_WRITE |
-                    FileObserver.MOVE_SELF | FileObserver.MOVED_FROM | FileObserver.MOVED_TO |
-                    FileObserver.DELETE | FileObserver.DELETE_SELF | FileObserver.CREATE;
-
-            if (observers != null) {
+            if (mWatcher != null) {
                 call.reject("already watching");
                 return;
             }
-            observers = new ArrayList<>();
-            observers.add(new SingleFileObserver(pathObj, mask));
-
-            // NOTE: only watch first level of directory
-            File[] files = pathObj.listFiles();
-            if (files != null) {
-                for (File file : files) {
-                    String filename = file.getName();
-                    if (file.isDirectory() && !filename.startsWith(".") && !filename.equals("bak") && !filename.equals("version-files") && !filename.equals("node_modules")) {
-                        observers.add(new SingleFileObserver(file, mask));
-                    }
-                }
-            }
 
-            this.initialNotify(pathObj);
+            mWatcher = new PollingFsWatcher(mPath);
+            mThread = new Thread(mWatcher);
+            mThread.start();
 
-            for (int i = 0; i < observers.size(); i++)
-                observers.get(i).startWatching();
             call.resolve();
         } else {
             call.reject(u.getScheme() + " scheme not supported");
@@ -92,77 +71,69 @@ public class FsWatcher extends Plugin {
     public void unwatch(PluginCall call) {
         Log.i("FsWatcher", "unwatch all...");
 
-        if (observers != null) {
-            for (int i = 0; i < observers.size(); ++i)
-                observers.get(i).stopWatching();
-            observers.clear();
-            observers = null;
+        if (mWatcher != null) {
+            mThread.interrupt();
+            mWatcher = null;
         }
 
         call.resolve();
     }
 
-    public void initialNotify(File pathObj) {
-        this.initialNotify(pathObj, 2);
-    }
-
-    public void initialNotify(File pathObj, int maxDepth) {
-        if (maxDepth == 0) {
-            return;
-        }
-        File[] files = pathObj.listFiles();
-        if (files != null) {
-            for (File file : files) {
-                String filename = file.getName();
-                if (file.isDirectory() && !filename.startsWith(".") && !filename.equals("bak") && !filename.equals("version-files") && !filename.equals("node_modules")) {
-                    this.initialNotify(file, maxDepth - 1);
-                } else if (file.isFile()
-                        && Pattern.matches("(?i)[^.].*?\\.(md|org|css|edn|js|markdown)$",
-                        file.getName())) {
-                    this.onObserverEvent(FileObserver.CREATE, file.getAbsolutePath());
-                }
-            }
-        }
-    }
-
     // add, change, unlink events
-    public void onObserverEvent(int event, String path) {
+    public void onObserverEvent(int event, String path, SimpleFileMetadata metadata) {
         JSObject obj = new JSObject();
         String content = null;
         File f = new File(path);
+
+        boolean shouldRead = false;
+        if (Pattern.matches("(?i)[^.].*?\\.(md|org|css|edn|js|markdown|excalidraw)$", f.getName())) {
+            shouldRead = true;
+        }
+
         obj.put("path", Uri.fromFile(f));
         obj.put("dir", Uri.fromFile(new File(mPath)));
+        JSObject stat;
 
         switch (event) {
-            case FileObserver.CLOSE_WRITE:
+            case FileObserver.MODIFY:
                 obj.put("event", "change");
-                try {
-                    obj.put("stat", getFileStat(path));
-                    content = getFileContents(f);
-                } catch (IOException | ErrnoException e) {
-                    e.printStackTrace();
+                stat = new JSObject();
+                stat.put("mtime", metadata.mtime);
+                stat.put("ctime", metadata.ctime);
+                stat.put("size", metadata.size);
+                obj.put("stat", stat);
+                if (shouldRead) {
+                    try {
+                        content = getFileContents(f);
+                    } catch (IOException e) {
+                        Log.e("FsWatcher", "error reading modified file");
+                        e.printStackTrace();
+                    }
                 }
+
                 Log.i("FsWatcher", "prepare event " + obj);
                 obj.put("content", content);
                 break;
-            case FileObserver.MOVED_TO:
             case FileObserver.CREATE:
                 obj.put("event", "add");
-                try {
-                    obj.put("stat", getFileStat(path));
-                    content = getFileContents(f);
-                } catch (IOException | ErrnoException e) {
-                    e.printStackTrace();
+                stat = new JSObject();
+                stat.put("mtime", metadata.mtime);
+                stat.put("ctime", metadata.ctime);
+                stat.put("size", metadata.size);
+                obj.put("stat", stat);
+                if (shouldRead) {
+                    try {
+                        content = getFileContents(f);
+                    } catch (IOException e) {
+                        Log.e("FsWatcher", "error reading new file");
+                        e.printStackTrace();
+                    }
                 }
-                Log.i("FsWatcher", "prepare event " + obj);
                 obj.put("content", content);
                 break;
-            case FileObserver.MOVE_SELF:
-            case FileObserver.MOVED_FROM:
             case FileObserver.DELETE:
-            case FileObserver.DELETE_SELF:
                 if (f.exists()) {
-                    Log.i("FsWatcher", "abandon notification due to file exists");
+                    Log.i("FsWatcher", "abandon delete notification due to file exists");
                     return;
                 } else {
                     obj.put("event", "unlink");
@@ -193,59 +164,127 @@ public class FsWatcher extends Plugin {
         return outputStream.toString("utf-8");
     }
 
-    public static JSObject getFileStat(final String path) throws ErrnoException {
-        File file = new File(path);
-        StructStat stat = Os.stat(path);
-        JSObject obj = new JSObject();
-        obj.put("atime", stat.st_atime);
-        obj.put("mtime", stat.st_mtime);
-        obj.put("ctime", stat.st_ctime);
-        obj.put("size", file.length());
-        return obj;
+    public class SimpleFileMetadata {
+        public long mtime;
+        public long ctime;
+        public long size;
+        public long ino;
+
+        public SimpleFileMetadata(File file) throws ErrnoException {
+            StructStat stat = Os.stat(file.getPath());
+            mtime = stat.st_mtime;
+            ctime = stat.st_ctime;
+            size = stat.st_size;
+            ino = stat.st_ino;
+        }
+
+        public boolean equals(SimpleFileMetadata other) {
+            return mtime == other.mtime && ctime == other.ctime && size == other.size && ino == other.ino;
+        }
     }
 
-    private class SingleFileObserver extends FileObserver {
-        private final String mPath;
 
-        public SingleFileObserver(String path, int mask) {
-            super(path, mask);
-            mPath = path;
-        }
+    public class PollingFsWatcher implements Runnable {
+        private String mPath;
+        private Map<String, SimpleFileMetadata> metaDb;
+
+        public PollingFsWatcher(String path) {
+            metaDb = new HashMap();
 
-        @SuppressLint("NewApi")
-        public SingleFileObserver(File path, int mask) {
-            super(path, mask);
-            mPath = path.getAbsolutePath();
+            File dir = new File(path);
+            try {
+                mPath = dir.getCanonicalPath();
+            } catch (IOException e) {
+                e.printStackTrace();
+            }
         }
 
         @Override
-        public void onEvent(int event, String path) {
-            if (path != null && !path.equals("graphs-txid.edn") && !path.equals("broken-config.edn")) {
-                Log.d("FsWatcher", "got path=" + mPath + "/" + path + " event=" + event);
-                // TODO: handle newly created directory
-                if (Pattern.matches("(?i)[^.].*?\\.(md|org|css|edn|js|markdown)$", path)) {
-                    String fullPath = mPath + "/" + path;
-                    if (event == FileObserver.MOVE_SELF || event == FileObserver.MOVED_FROM ||
-                        event == FileObserver.DELETE || event == FileObserver.DELETE_SELF) {
-                        Log.d("FsWatcher", "defer delete notification for " + path);
-                        Thread timer = new Thread() {
-                            @Override
-                            public void run() {
-                                try {
-                                    // delay 500ms then send, enough for most syncing net disks
-                                    Thread.sleep(500);
-                                    FsWatcher.this.onObserverEvent(event, fullPath);
-                                } catch (InterruptedException e) {
-                                    e.printStackTrace();
-                                }
+        public void run() {
+            this.tick(false); // skip initial notification
+
+            while (!Thread.currentThread().isInterrupted()) {
+                try {
+                    this.tick(true);
+                    Thread.sleep(2000); // The same as iOS fswatcher, 2s interval
+                } catch (InterruptedException e) {
+                    // e.printStackTrace();
+                    Log.i("FsWatcher", "interrupted, unwatch");
+                    break;
+                }
+            }
+
+        }
+
+        private void tick(boolean shouldNotify) {
+            Map<String, SimpleFileMetadata> newMetaDb = new HashMap();
+
+            Stack<String> paths = new Stack();
+            paths.push(mPath);
+            while (!paths.isEmpty()) {
+                String dir = paths.pop();
+                File curr = new File(dir);
+
+                File[] files = curr.listFiles();
+                if (files != null) {
+                    for (File file : files) {
+                        String filename = file.getName();
+                        if (file.isDirectory()) {
+                            if (!filename.startsWith(".") && !filename.equals("bak") && !filename.equals("version-files") && !filename.equals("node_modules")) {
+                                paths.push(file.getAbsolutePath());
+                            }
+                        } else if (file.isFile() && !filename.equals("graphs-txid.edn") && !filename.equals("broken-config.edn")) {
+                            try {
+                                SimpleFileMetadata metadata = new SimpleFileMetadata(file);
+                                newMetaDb.put(file.getAbsolutePath(), metadata);
+                            } catch (ErrnoException e) {
                             }
-                        };
-                        timer.start();
-                    } else {
-                        FsWatcher.this.onObserverEvent(event, fullPath);
+                        }
                     }
                 }
             }
+
+            if (shouldNotify) {
+                this.updateMetaDb(newMetaDb);
+            } else {
+                this.metaDb = newMetaDb;
+            }
+        }
+
+        private void updateMetaDb(Map<String, SimpleFileMetadata> newMetaDb) {
+            for (Map.Entry<String, SimpleFileMetadata> entry : newMetaDb.entrySet()) {
+                String path = entry.getKey();
+                SimpleFileMetadata newMeta = entry.getValue();
+                SimpleFileMetadata oldMeta = metaDb.remove(path);
+                if (oldMeta == null) {
+                    // new file
+                    onObserverEvent(FileObserver.CREATE, path, newMeta);
+                    Log.d("FsWatcher", "create " + path);
+                } else if (!oldMeta.equals(newMeta)) {
+                    // file changed
+                    onObserverEvent(FileObserver.MODIFY, path, newMeta);
+                    Log.d("FsWatcher", "changed " + path);
+                }
+            }
+            for (String path : metaDb.keySet()) {
+                // file deleted
+                Thread timer = new Thread() {
+                    @Override
+                    public void run() {
+                        try {
+                            // delay 500ms then send, enough for most syncing net disks
+                            Thread.sleep(500);
+                            onObserverEvent(FileObserver.DELETE, path, null);
+                            Log.d("FsWatcher", "deleted " + path);
+                        } catch (InterruptedException e) {
+                            e.printStackTrace();
+                        }
+                    }
+                };
+                timer.start();
+            }
+
+            this.metaDb = newMetaDb;
         }
     }
 }

+ 2 - 0
android/app/src/main/res/values/colors.xml

@@ -1,4 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <color name="logoPrimary">#002b36</color>
+    <color name="colorPrimary">#ffffff</color>
+    <color name="colorPrimaryDark">#002b36</color>
 </resources>

+ 2 - 0
android/app/src/main/res/values/styles.xml

@@ -14,6 +14,8 @@
         <item name="windowNoTitle">true</item>
         <item name="android:background">@null</item>
         <item name="android:windowIsTranslucent">true</item>
+        <item name="android:navigationBarColor">@color/colorPrimary</item>
+        <item name="android:statusBarColor">@color/colorPrimary</item>
     </style>
 
     <!-- App Starting -->

+ 3 - 0
android/capacitor.settings.gradle

@@ -32,6 +32,9 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
 include ':capawesome-capacitor-background-task'
 project(':capawesome-capacitor-background-task').projectDir = new File('../node_modules/@capawesome/capacitor-background-task/android')
 
+include ':hugotomazi-capacitor-navigation-bar'
+project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/@hugotomazi/capacitor-navigation-bar/android')
+
 include ':logseq-capacitor-file-sync'
 project(':logseq-capacitor-file-sync').projectDir = new File('../node_modules/@logseq/capacitor-file-sync/android')
 

+ 6 - 6
bb.edn

@@ -1,20 +1,17 @@
 {:paths ["scripts/src" "src/main"]
  :deps
- {org.babashka/spec.alpha
-  {:git/url "https://github.com/babashka/spec.alpha"
-   :sha "1a841c4cc1d4f6dab7505a98ed2d532dd9d56b78"}
-  metosin/malli
+ {metosin/malli
   {:mvn/version "0.9.1"}
   logseq/bb-tasks
   #_{:local/root "../bb-tasks"}
   {:git/url "https://github.com/logseq/bb-tasks"
-   :git/sha "95e4fbdb7bbf1c720c6f8b58e3b3b96b3b487526"}
+   :git/sha "4295d5df0458cc06a09c5d506510ee49b785407d"}
   logseq/graph-parser
   {:local/root "deps/graph-parser"}
   org.clj-commons/digest
   {:mvn/version "1.4.100"}}
  :pods
- {clj-kondo/clj-kondo {:version "2022.02.09"}
+ {clj-kondo/clj-kondo {:version "2022.10.05"}
   org.babashka/fswatcher {:version "0.0.3"}}
  :tasks
  {dev:desktop-watch
@@ -66,6 +63,9 @@
   dev:validate-plugins-edn
   logseq.tasks.malli/validate-plugins-edn
 
+  dev:validate-config-edn
+  logseq.tasks.malli/validate-config-edn
+
   dev:lint
   logseq.tasks.dev/lint
 

+ 8 - 1
capacitor.config.ts

@@ -1,4 +1,7 @@
 import { CapacitorConfig } from '@capacitor/cli'
+import fs from 'fs'
+
+const version = fs.readFileSync('static/package.json', 'utf8').match(/"version": "(.*?)"/)?.at(1) ?? '0.0.0'
 
 const config: CapacitorConfig = {
   appId: 'com.logseq.app',
@@ -18,8 +21,12 @@ const config: CapacitorConfig = {
       resize: 'none'
     }
   },
+  android: {
+    appendUserAgent: `Logseq/${version} (Android)`
+  },
   ios: {
-    scheme: 'Logseq'
+    scheme: 'Logseq',
+    appendUserAgent: `Logseq/${version} (iOS)`
   },
   cordova: {
     staticPlugins: [

+ 3 - 6
deps.edn

@@ -4,9 +4,7 @@
   rum/rum                               {:mvn/version "0.12.9"}
   datascript/datascript                 {:mvn/version "1.3.8"}
   datascript-transit/datascript-transit {:mvn/version "0.3.0"}
-  ;; TODO: bump to mvn/version when released
-  borkdude/rewrite-edn                  {:git/url "https://github.com/borkdude/rewrite-edn"
-                                         :sha     "80f246139b1a43b6f2cbab329521d060ee7c1b7b"}
+  borkdude/rewrite-edn                  {:mvn/version "0.4.6"}
   funcool/promesa                       {:mvn/version "4.0.2"}
   medley/medley                         {:mvn/version "1.4.0"}
   metosin/reitit-frontend               {:mvn/version "0.3.10"}
@@ -18,8 +16,7 @@
   cljs-drag-n-drop/cljs-drag-n-drop     {:mvn/version "0.1.0"}
   cljs-http/cljs-http                   {:mvn/version "0.1.46"}
   org.babashka/sci                      {:mvn/version "0.3.2"}
-  hickory/hickory                       {:git/url "https://github.com/clj-commons/hickory"
-                                         :sha     "a308fafdf1e0483087a105544f2cb190cc3289b0"}
+  org.clj-commons/hickory               {:mvn/version "0.7.3"}
   hiccups/hiccups                       {:mvn/version "0.3.0"}
   tongue/tongue                         {:mvn/version "0.4.4"}
   org.clojure/core.async                {:mvn/version "1.3.610"}
@@ -53,5 +50,5 @@
                    :main-opts ["-m" "cljs-test-runner.main" "-d" "src/bench" "-n" "frontend.benchmark-test-runner"]}
 
            ;; Use :replace-deps for tools. See https://github.com/clj-kondo/clj-kondo/issues/1536#issuecomment-1013006889
-           :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.10.14"}}
+           :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.12.08"}}
                        :main-opts  ["-m" "clj-kondo.main"]}}}

+ 2 - 2
deps/db/bb.edn

@@ -7,7 +7,7 @@
    :git/sha "1815db538241082a01e95601e23e4290dd64d0c0"}}
 
  :pods
- {clj-kondo/clj-kondo {:version "2022.02.09"}}
+ {clj-kondo/clj-kondo {:version "2022.10.05"}}
 
  :tasks
  {test:load-all-namespaces-with-nbb
@@ -27,7 +27,7 @@
               [logseq.db.rules :as rules])
    :doc "Lint datalog rules for parsability and unbound variables"
    :task (datalog/lint-rules
-          (into rules/rules
+          (into (mapcat val rules/rules)
                 (-> rules/query-dsl-rules
                     ;; TODO: Update linter to handle false positive on ?str-val
                     (dissoc :property)

+ 1 - 1
deps/db/deps.edn

@@ -3,5 +3,5 @@
  {datascript/datascript {:mvn/version "1.3.8"}}
  :aliases
  {:clj-kondo
-  {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.10.14"}}
+  {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.12.08"}}
    :main-opts  ["-m" "clj-kondo.main"]}}}

+ 56 - 55
deps/db/src/logseq/db/rules.cljc

@@ -2,62 +2,63 @@
   "Datalog rules for use with logseq.db.schema")
 
 (def ^:large-vars/data-var rules
+  "Rules used mainly in frontend.db.model"
   ;; rule "parent" is optimized for parent node -> child node nesting queries
-  '[[(parent ?p ?c)
-     [?c :block/parent ?p]]
-    [(parent ?p ?c)
-     [?c :block/parent ?t]
-     (parent ?p ?t)]
-
-  ;; rule "child" is optimized for child node -> parent node nesting queries
-    [(child ?p ?c)
-     [?c :block/parent ?p]]
-    [(child ?p ?c)
-     [?t :block/parent ?p]
-     (child ?t ?c)]
-
-  ;; rule "namespace" is optimized for child node -> node of upper namespace level nesting queries
-    [(namespace ?p ?c)
-     [?c :block/namespace ?p]]
-    [(namespace ?p ?c)
-     [?t :block/namespace ?p]
-     (namespace ?t ?c)]
-
-    ;; Select rules carefully, as it is critical for performance.
-    ;; The rules have different clause order and resolving directions.
-    ;; Clause order Reference:
-    ;; 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
-    ;; 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)
-    ;;   (?c :ml/parent ?p)]
-    ;;  [(ubersymbol ?c ?p)
-    ;;   ;; we bind a child of the ancestor, instead of a parent of the descendant
-    ;;   (?c1 :ml/parent ?p)
-    ;;   (ubersymbol ?c ?c1)]]
-
-    ;; This way of writing the ruleset is optimized to find the descendants of some node. The way you originally wrote it is optimized to find the anscestors of some node.
-
-    ;; from https://stackoverflow.com/questions/43784258/find-entities-whose-ref-to-many-attribute-contains-all-elements-of-input
-    ;; Quote:
-    ;; You're tackling the general problem of 'dynamic conjunction' in Datomic's Datalog.
-    ;; Write a dynamic Datalog query which uses 2 negations and 1 disjunction or a recursive rule
-    ;; Datalog has no direct way of expressing dynamic conjunction (logical AND / 'for all ...' / set intersection).
-    ;; However, you can achieve it in pure Datalog by combining one disjunction
-    ;; (logical OR / 'exists ...' / set union) and two negations, i.e
-    ;; (For all ?g in ?Gs p(?e,?g)) <=> NOT(Exists ?g in ?Gs, such that NOT(p(?e, ?g)))
-
-    ;; [(matches-all ?e ?a ?vs)
-    ;;  [(first ?vs) ?v0]
-    ;;  [?e ?a ?v0]
-    ;;  (not-join [?e ?vs]
-    ;;            [(identity ?vs) [?v ...]]
-    ;;            (not-join [?e ?v]
-    ;;                      [?e ?a ?v]))]
-    ])
+  {:namespace
+   '[[(namespace ?p ?c)
+      [?c :block/namespace ?p]]
+     [(namespace ?p ?c)
+      [?t :block/namespace ?p]
+      (namespace ?t ?c)]]
+
+   :alias
+   '[[(alias ?e2 ?e1)
+      [?e2 :block/alias ?e1]]
+     [(alias ?e2 ?e1)
+      [?e1 :block/alias ?e2]]
+     [(alias ?e1 ?e3)
+      [?e1 :block/alias ?e2]
+      [?e2 :block/alias ?e3]]
+     [(alias ?e3 ?e1)
+      [?e1 :block/alias ?e2]
+      [?e2 :block/alias ?e3]]]})
+
+;; Rules writing advice
+;; ====================
+;; Select rules carefully, as it is critical for performance.
+;; The rules have different clause order and resolving directions.
+;; Clause order Reference:
+;; 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
+;; 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)
+;;   (?c :ml/parent ?p)]
+;;  [(ubersymbol ?c ?p)
+;;   ;; we bind a child of the ancestor, instead of a parent of the descendant
+;;   (?c1 :ml/parent ?p)
+;;   (ubersymbol ?c ?c1)]]
+
+;; This way of writing the ruleset is optimized to find the descendants of some node. The way you originally wrote it is optimized to find the anscestors of some node.
+
+;; from https://stackoverflow.com/questions/43784258/find-entities-whose-ref-to-many-attribute-contains-all-elements-of-input
+;; Quote:
+;; You're tackling the general problem of 'dynamic conjunction' in Datomic's Datalog.
+;; Write a dynamic Datalog query which uses 2 negations and 1 disjunction or a recursive rule
+;; Datalog has no direct way of expressing dynamic conjunction (logical AND / 'for all ...' / set intersection).
+;; However, you can achieve it in pure Datalog by combining one disjunction
+;; (logical OR / 'exists ...' / set union) and two negations, i.e
+;; (For all ?g in ?Gs p(?e,?g)) <=> NOT(Exists ?g in ?Gs, such that NOT(p(?e, ?g)))
+
+;; [(matches-all ?e ?a ?vs)
+;;  [(first ?vs) ?v0]
+;;  [?e ?a ?v0]
+;;  (not-join [?e ?vs]
+;;            [(identity ?vs) [?v ...]]
+;;            (not-join [?e ?v]
+;;                      [?e ?a ?v]))]
 
 (def ^:large-vars/data-var query-dsl-rules
   "Rules used by frontend.db.query-dsl. The symbols ?b and ?p respectively refer

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

@@ -32,3 +32,5 @@ logseq.graph-parser.property/->block-content
 logseq.graph-parser.property/property-value-from-content
 ;; API
 logseq.graph-parser.whiteboard/page-block->tldr-page
+;; API
+logseq.graph-parser/get-blocks-to-delete

+ 1 - 1
deps/graph-parser/bb.edn

@@ -6,7 +6,7 @@
    :git/sha "1815db538241082a01e95601e23e4290dd64d0c0"}}
  
  :pods
- {clj-kondo/clj-kondo {:version "2022.02.09"}}
+ {clj-kondo/clj-kondo {:version "2022.10.05"}}
 
  :tasks
  {test:load-all-namespaces-with-nbb

+ 1 - 1
deps/graph-parser/deps.edn

@@ -19,5 +19,5 @@
                       org.clojure/clojurescript {:mvn/version "1.11.54"}}
          :main-opts ["-m" "cljs-test-runner.main"]}
 
-  :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.10.14"}}
+  :clj-kondo {:replace-deps {clj-kondo/clj-kondo {:mvn/version "2022.12.08"}}
               :main-opts  ["-m" "clj-kondo.main"]}}}

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

@@ -6,11 +6,77 @@
             [logseq.graph-parser.util :as gp-util]
             [logseq.graph-parser.date-time-util :as date-time-util]
             [logseq.graph-parser.config :as gp-config]
+            [logseq.db.schema :as db-schema]
             [clojure.string :as string]
             [clojure.set :as set]))
 
+(defn- retract-blocks-tx
+  [blocks retain-uuids]
+  (mapcat (fn [{uuid :block/uuid eid :db/id}]
+            (if (and uuid (contains? retain-uuids uuid))
+              (map (fn [attr] [:db.fn/retractAttribute eid attr]) db-schema/retract-attributes)
+              [[:db.fn/retractEntity eid]]))
+          blocks))
+
+(defn- get-file-page
+  "Copy of db/get-file-page. Too basic to couple to main app"
+  [db file-path]
+  (ffirst
+   (d/q
+    '[:find ?page-name
+      :in $ ?path
+      :where
+      [?file :file/path ?path]
+      [?page :block/file ?file]
+      [?page :block/original-name ?page-name]]
+    db
+    file-path)))
+
+(defn- get-page-blocks-no-cache
+  "Copy of db/get-page-blocks-no-cache. Too basic to couple to main app"
+  [db page {:keys [pull-keys]
+            :or {pull-keys '[*]}}]
+  (let [sanitized-page (gp-util/page-name-sanity-lc page)
+        page-id (:db/id (d/entity db [:block/name sanitized-page]))]
+    (when page-id
+      (let [datoms (d/datoms db :avet :block/page page-id)
+            block-eids (mapv :e datoms)]
+        (d/pull-many db pull-keys block-eids)))))
+
+(defn get-blocks-to-delete
+  "Returns the transactional operations to retract blocks belonging to the
+  given page name and file path. This function is required when a file is being
+  parsed from disk; before saving the parsed, blocks from the previous version
+  of that file need to be retracted.
+
+  The 'Page' parsed from the new file version is passed separately from the
+  file-path, as the page name can be set via properties in the file, and thus
+  can change between versions. If it has changed, existing blocks for both the
+  old and new page name will be retracted.
+
+  Blocks are by default fully cleared via retractEntity. However, a collection
+  of block UUIDs to retain can be passed, and any blocks with matching uuids
+  will instead have their attributes cleared individually via
+  'retractAttribute'. This will preserve block references to the retained
+  UUIDs."
+  [db file-page file-path retain-uuid-blocks]
+  (let [existing-file-page (get-file-page db file-path)
+        pages-to-clear (distinct (filter some? [existing-file-page (:block/name file-page)]))
+        blocks (mapcat (fn [page]
+                         (get-page-blocks-no-cache db page {:pull-keys [:db/id :block/uuid]}))
+                       pages-to-clear)
+        retain-uuids (set (keep :block/uuid retain-uuid-blocks))]
+    (retract-blocks-tx (distinct blocks) retain-uuids)))
+
 (defn parse-file
-  "Parse file and save parsed data to the given db. Main parse fn used by logseq app"
+  "Parse file and save parsed data to the given db. Main parse fn used by logseq app.
+Options available:
+
+* :new? - Boolean which indicates if this file already exists. Default is true.
+* :delete-blocks-fn - Optional fn which is called with the new page, file and existing block uuids
+  which may be referenced elsewhere.
+* :skip-db-transact? - Boolean which skips transacting in order to batch transactions. Default is false
+* :extract-options - Options map to pass to extract/extract"
   [conn file content {:keys [new? delete-blocks-fn extract-options skip-db-transact?]
                       :or {new? true
                            delete-blocks-fn (constantly [])
@@ -29,21 +95,20 @@
               {:keys [pages blocks ast refs]
                :or   {pages []
                       blocks []
-                      ast []
-                      refs []}}
+                      ast []}}
               (cond
-                (gp-config/whiteboard? file)
-                (extract/extract-whiteboard-edn file content extract-options')
+                (contains? gp-config/mldoc-support-formats format)
+                (extract/extract file content extract-options')
 
                 (string/ends-with? file ".edn")
                 (extract/extract-from-edn file content extract-options')
 
-                (contains? gp-config/mldoc-support-formats format)
-                (extract/extract file content extract-options')
+                (gp-config/whiteboard? file)
+                (extract/extract-whiteboard-edn file content extract-options')
 
                 :else nil)
-              delete-blocks (delete-blocks-fn (first pages) file)
               block-ids (map (fn [block] {:block/uuid (:block/uuid block)}) blocks)
+              delete-blocks (delete-blocks-fn @conn (first pages) file block-ids)
               block-refs-ids (->> (mapcat :block/refs blocks)
                                   (filter (fn [ref] (and (vector? ref)
                                                          (= :block/uuid (first ref)))))
@@ -53,7 +118,7 @@
               block-ids (set/union (set block-ids) (set block-refs-ids))
               pages (extract/with-ref-pages pages blocks)
               pages-index (map #(select-keys % [:block/name]) pages)]
-          (frontend.util/pprint {:pages pages
+          #_(frontend.util/pprint {:pages pages
                                  :blocks blocks
                                  :refs refs})
           {:tx (concat file-content refs pages-index delete-blocks pages block-ids blocks)

+ 6 - 3
deps/graph-parser/src/logseq/graph_parser/cli.cljs

@@ -49,7 +49,8 @@ TODO: Fail fast when process exits 1"
     (mapv
      (fn [{:file/keys [path content]}]
        (let [{:keys [ast]}
-             (graph-parser/parse-file conn path content {:extract-options extract-options})]
+             (graph-parser/parse-file conn path content (merge {:extract-options extract-options}
+                                                               (:parse-file-options options)))]
          {:file path :ast ast}))
      files)))
 
@@ -59,12 +60,14 @@ TODO: Fail fast when process exits 1"
   as it can't assume that the metadata in logseq/ is up to date. Directory is
   assumed to be using git. This fn takes the following options:
 * :verbose - When enabled prints more information during parsing. Defaults to true
-* :files - Specific files to parse instead of parsing the whole directory"
+* :files - Specific files to parse instead of parsing the whole directory
+* :conn - Database connection to use instead of creating new one
+* :parse-file-options - Options map to pass to graph-parser/parse-file"
   ([dir]
    (parse-graph dir {}))
   ([dir options]
    (let [files (or (:files options) (build-graph-files dir))
-         conn (ldb/start-conn)
+         conn (or (:conn options) (ldb/start-conn))
          config (read-config dir)
         _ (when-not (:files options) (println "Parsing" (count files) "files..."))
          asts (parse-files conn files (merge options {:config config}))]

+ 6 - 6
deps/graph-parser/src/logseq/graph_parser/text.cljs

@@ -71,12 +71,12 @@
        (remove-level-space-aux! text block-pattern space? trim-left?)))))
 
 (defn namespace-page?
-  [p]
-  (and (string? p)
-       (string/includes? p "/")
-       (not (string/starts-with? p "../"))
-       (not (string/starts-with? p "./"))
-       (not (gp-util/url? p))))
+  [page-name]
+  (and (string? page-name)
+       (string/includes? page-name "/")
+       (not (string/starts-with? page-name "../"))
+       (not (string/starts-with? page-name "./"))
+       (not (gp-util/url? page-name))))
 
 (defn parse-non-string-property-value
   "Return parsed non-string property value or nil if none is found"

+ 0 - 1
deps/graph-parser/src/logseq/graph_parser/util.cljs

@@ -172,7 +172,6 @@
   [format]
   (case (keyword format)
     :md :markdown
-    :asciidoc :adoc
     ;; default
     (keyword format)))
 

+ 16 - 7
deps/graph-parser/src/logseq/graph_parser/whiteboard.cljs

@@ -43,15 +43,24 @@
 
 
 (defn- get-shape-refs [shape]
-  (when (= "logseq-portal" (:type shape))
-    [(if (= (:blockType shape) "P")
-       {:block/name (gp-util/page-name-sanity-lc (:pageId shape))}
-       {:block/uuid (uuid (:pageId shape))})]))
+  (let [portal-refs (when (= "logseq-portal" (:type shape))
+                      [(if (= (:blockType shape) "P")
+                         {:block/name (gp-util/page-name-sanity-lc (:pageId shape))}
+                         {:block/uuid (uuid (:pageId shape))})])
+        shape-link-refs (->> (:refs shape)
+                             (filter (complement empty?))
+                             (map (fn [ref] (if (parse-uuid ref)
+                                              {:block/uuid (parse-uuid ref)}
+                                              {:block/name (gp-util/page-name-sanity-lc ref)}))))]
+    (concat portal-refs shape-link-refs)))
 
 (defn- with-whiteboard-block-refs
-  [shape]
+  [shape page-name]
   (let [refs (or (get-shape-refs shape) [])]
-    (merge {:block/refs refs})))
+    (merge {:block/refs (if (seq refs) refs [])
+            :block/path-refs (if (seq refs)
+                               (conj refs {:block/name page-name})
+                               [])})))
 
 (defn- with-whiteboard-content
   "Main purpose of this function is to populate contents when shapes are used as references in outliner."
@@ -72,7 +81,7 @@
     (merge (if shape?
              (merge
               {:block/uuid (uuid (:id shape))}
-              (with-whiteboard-block-refs shape)
+              (with-whiteboard-block-refs shape page-name)
               (with-whiteboard-content shape))
 
              ;; TODO: remove?

+ 1 - 1
deps/graph-parser/test/logseq/graph_parser_test.cljs

@@ -74,7 +74,7 @@
                                                         (throw (js/Error "Testing unexpected failure")))]
         (try
           (graph-parser/parse-file conn "foo.md" "- id:: 628953c1-8d75-49fe-a648-f4c612109098"
-                                   {:delete-blocks-fn (fn [page _file]
+                                   {:delete-blocks-fn (fn [_db page _file _uuids]
                                                         (reset! deleted-page page))})
           (catch :default _)))
       (is (= nil @deleted-page)

+ 12 - 0
docs/dev-practices.md

@@ -89,6 +89,18 @@ yarn electron-watch
 yarn e2e-test # or npx playwright test
 ```
 
+If e2e failed after first running:
+- `rm -rdf ~/.logseq`
+- `rm -rdf <repo dir>/tmp/`  
+- `rm -rdf <appData dir>/Electron`  (Reference: https://www.electronjs.org/de/docs/latest/api/app#appgetpathname)
+
+If e2e tests fail, they can be debugged by examining a trace dump with [the
+playwright trace
+viewer](https://playwright.dev/docs/trace-viewer#recording-a-trace). Locally
+this will get dumped into e2e-dump/. On CI the trace file will be under
+Artifacts at the bottom of a run page e.g.
+https://github.com/logseq/logseq/actions/runs/3574600322.
+
 ### Unit Testing
 
 Our unit tests use the [shadow-cljs test-runner](https://shadow-cljs.github.io/docs/UsersGuide.html#_testing). To run them:

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

@@ -246,4 +246,4 @@ test('Scheduled date picker should point to the already specified Date #6985', a
   // Close date picker
   await page.click('a.opacity-80')
   await page.waitForTimeout(500)
-})
+})

+ 0 - 2
e2e-tests/code-editing.spec.ts

@@ -194,8 +194,6 @@ test('click outside to exit', async ({ page }) => {
 test('click language label to exit #3463', async ({ page, block }) => {
   await createRandomPage(page)
 
-  await block.enterNext();
-
   await page.fill('.block-editor textarea', '```cpp\n```')
   await page.waitForTimeout(200)
   await escapeToCodeEditor(page)

+ 52 - 30
e2e-tests/editor.spec.ts

@@ -29,6 +29,25 @@ test('hashtag and quare brackets in same line #4178', async ({ page }) => {
   )
 })
 
+test('hashtag search page auto-complete', async ({ page, block }) => {
+  await createRandomPage(page)
+
+  await block.activeEditing(0)
+
+  await page.type('textarea >> nth=0', '#', { delay: 100 })
+  await page.waitForSelector('text="Search for a page"', { state: 'visible' })
+  await page.keyboard.press('Escape', { delay: 50 })
+
+  await block.mustFill("done")
+
+  await enterNextBlock(page)
+  await page.type('textarea >> nth=0', 'Some #', { delay: 100 })
+  await page.waitForSelector('text="Search for a page"', { state: 'visible' })
+  await page.keyboard.press('Escape', { delay: 50 })
+
+  await block.mustFill("done")
+})
+
 test('disappeared children #4814', async ({ page, block }) => {
   await createRandomPage(page)
 
@@ -148,9 +167,10 @@ test(
 test('copy & paste block ref and replace its content', async ({ page, block }) => {
   await createRandomPage(page)
 
-  await block.mustFill('Some random text')
-  // FIXME: copy instantly will make content disappear
+  await block.mustType('Some random text')
+  // FIXME: https://github.com/logseq/logseq/issues/7541
   await page.waitForTimeout(1000)
+
   if (IsMac) {
     await page.keyboard.press('Meta+c')
   } else {
@@ -158,6 +178,8 @@ test('copy & paste block ref and replace its content', async ({ page, block }) =
   }
 
   await page.press('textarea >> nth=0', 'Enter')
+  await block.waitForBlocks(2)
+
   if (IsMac) {
     await page.keyboard.press('Meta+v')
   } else {
@@ -165,54 +187,58 @@ test('copy & paste block ref and replace its content', async ({ page, block }) =
   }
   await page.keyboard.press('Enter')
 
-  const blockRef = page.locator('.block-ref >> text="Some random text"');
-
   // Check if the newly created block-ref has the same referenced content
-  await expect(blockRef).toHaveCount(1);
+  await expect(page.locator('.block-ref >> text="Some random text"')).toHaveCount(1);
 
   // Move cursor into the block ref
   for (let i = 0; i < 4; i++) {
     await page.press('textarea >> nth=0', 'ArrowLeft')
   }
 
+  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+v')
+    await page.keyboard.press('Control+Shift+r')
   }
+  await expect(page.locator('textarea >> nth=0')).toHaveValue('Some random text')
+  await block.escapeEditing()
+
+  await expect(page.locator('.block-ref >> text="Some random text"')).toHaveCount(0);
+  await expect(page.locator('text="Some random text"')).toHaveCount(2);
 })
 
 test('copy and paste block after editing new block #5962', async ({ page, block }) => {
   await createRandomPage(page)
 
   // Create a block and copy it in block-select mode
-  await block.mustFill('Block being copied')
-  await page.waitForTimeout(100)
+  await block.mustType('Block being copied')
   await page.keyboard.press('Escape')
-  await page.waitForTimeout(100)
+  await expect(page.locator('.ls-block.selected')).toHaveCount(1)
+
   if (IsMac) {
-    await page.keyboard.press('Meta+c')
+    await page.keyboard.press('Meta+c', { delay: 10 })
   } else {
-    await page.keyboard.press('Control+c')
+    await page.keyboard.press('Control+c', { delay: 10 })
   }
-  // await page.waitForTimeout(100)
+
   await page.keyboard.press('Enter')
-  await page.waitForTimeout(100)
+  await expect(page.locator('.ls-block.selected')).toHaveCount(0)
+  await expect(page.locator('textarea >> nth=0')).toBeVisible()
   await page.keyboard.press('Enter')
+  await block.waitForBlocks(2)
 
-  await page.waitForTimeout(100)
-  // Create a new block with some text
-  await page.keyboard.insertText("Typed block")
+  await block.mustType('Typed block')
 
-  // Quickly paste the copied block
   if (IsMac) {
     await page.keyboard.press('Meta+v')
   } else {
     await page.keyboard.press('Control+v')
   }
-
-  await expect(page.locator('text="Typed block"')).toHaveCount(1);
+  await expect(page.locator('text="Typed block"')).toHaveCount(1)
+  await block.waitForBlocks(3)
 })
 
 test('undo and redo after starting an action should not destroy text #6267', async ({ page, block }) => {
@@ -497,7 +523,7 @@ test('press escape when link/image dialog is open, should restore focus to input
 })
 
 test('should show text after soft return when node is collapsed #5074', async ({ page, block }) => {
-  const delay = 100
+  const delay = 300
   await createRandomPage(page)
 
   await page.type('textarea >> nth=0', 'Before soft return', { delay: 10 })
@@ -507,11 +533,10 @@ test('should show text after soft return when node is collapsed #5074', async ({
   await block.enterNext()
   expect(await block.indent()).toBe(true)
   await block.mustType('Child text')
-  await page.waitForTimeout(delay)
 
   // collapse
   await page.click('.block-control >> nth=0')
-  await page.waitForTimeout(delay)
+  await block.waitForBlocks(1)
 
   // select the block that has the soft return
   await page.keyboard.press('ArrowDown')
@@ -519,13 +544,12 @@ test('should show text after soft return when node is collapsed #5074', async ({
   await page.keyboard.press('Enter')
   await page.waitForTimeout(delay)
 
-  expect(await page.inputValue('textarea >> nth=0')).toBe(
-    'Before soft return\nAfter soft return'
-  )
+  await expect(page.locator('textarea >> nth=0')).toHaveText('Before soft return\nAfter soft return')
 
   // zoom into the block
-  await page.click('a.block-control + a')
-  await page.waitForTimeout(delay)
+  page.click('a.block-control + a')
+  await page.waitForNavigation()
+  await page.waitForTimeout(delay * 3)
 
   // select the block that has the soft return
   await page.keyboard.press('ArrowDown')
@@ -533,9 +557,7 @@ test('should show text after soft return when node is collapsed #5074', async ({
   await page.keyboard.press('Enter')
   await page.waitForTimeout(delay)
 
-  expect(await page.inputValue('textarea >> nth=0')).toBe(
-    'Before soft return\nAfter soft return'
-  )
+  await expect(page.locator('textarea >> nth=0')).toHaveText('Before soft return\nAfter soft return')
 })
 
 test('should not erase typed text when expanding block quickly after typing #3891', async ({ page, block }) => {

+ 28 - 2
e2e-tests/fixtures.ts

@@ -70,6 +70,16 @@ base.beforeAll(async () => {
   console.log("Test start with:", info)
 
   page = await electronApp.firstWindow()
+
+  // inject testing flags
+  await page.evaluate(
+    () => {
+      Object.assign(window, {
+        __E2E_TESTING__: true,
+      })
+    },
+  )
+
   // Direct Electron console to watcher
   page.on('console', consoleLogWatcher)
   page.on('crash', () => {
@@ -108,6 +118,14 @@ base.beforeEach(async () => {
     await page.keyboard.press('Escape')
     await page.keyboard.press('Escape')
 
+    /*
+    const locator = page.locator('.notification-close-button').first()
+    while (await locator.isVisible()) {
+      locator.click() // ignore error
+    }
+    */
+    await expect(page.locator('.notification-close-button')).not.toBeVisible()
+
     const rightSidebar = page.locator('.cp__right-sidebar-inner')
     if (await rightSidebar.isVisible()) {
       await page.click('button.toggle-right-sidebar', {delay: 100})
@@ -119,6 +137,8 @@ base.afterAll(async () => {
   // if (electronApp) {
   //  await electronApp.close()
   //}
+  // use .dump as extension to avoid unfolded when zip by github
+  await context.tracing.stop({ path: `e2e-dump/trace-${Date.now()}.zip.dump` });
 })
 
 // hijack electron app into the test context
@@ -185,8 +205,14 @@ export const test = base.extend<LogseqFixtures>({
         await page.waitForSelector(`.ls-block.selected >> nth=${total - 1}`, { timeout: 1000 })
       },
       escapeEditing: async (): Promise<void> => {
-        await page.keyboard.press('Escape')
-        await page.keyboard.press('Escape')
+        const blockEdit = page.locator('.ls-block textarea >> nth=0')
+        while (await blockEdit.isVisible()) {
+          await page.keyboard.press('Escape')
+        }
+        const blockSelect = page.locator('.ls-block.selected')
+        while (await blockSelect.isVisible()) {
+          await page.keyboard.press('Escape')
+        }
       },
       activeEditing: async (nth: number): Promise<void> => {
         await page.waitForSelector(`.ls-block >> nth=${nth}`, { timeout: 1000 })

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

@@ -10,7 +10,7 @@ test("Logseq URLs (same graph)", async ({ page, block }) => {
   await block.mustFill(identify_text)
 
   // paste current page's URL to another page, then redirect throught the URL
-  await page.click('.ui__dropdown-trigger')
+  await page.click('.ui__dropdown-trigger .toolbar-dots-btn')
   await page.locator("text=Copy page URL").click()
   await createRandomPage(page)
   await block.mustFill("") // to enter editing mode

+ 2 - 1
e2e-tests/page-rename.spec.ts

@@ -74,7 +74,8 @@ async function homepage_rename_test(page: Page, original_page_name: string, new_
 }
 
 test('page rename test', async ({ page }) => {
-  await homepage_rename_test(page, "abcd", "a/b/c/d")
+  // TODO: Fix commented out test. Started failing after https://github.com/logseq/logseq/pull/6945
+  // await homepage_rename_test(page, "abcd", "a/b/c/d")
   await page_rename_test(page, "abcd", "a.b.c.d")
   await page_rename_test(page, "abcd", "a/b/c/d")
 

+ 102 - 64
e2e-tests/page-search.spec.ts

@@ -1,5 +1,6 @@
 import { expect, Page } from '@playwright/test'
 import { test } from './fixtures'
+import { Block } from './types'
 import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastBlock, enterNextBlock } from './utils'
 
 /***
@@ -8,48 +9,81 @@ import { IsMac, createRandomPage, newBlock, newInnerBlock, randomString, lastBlo
  * Consider diacritics
  ***/
 
- test('Search page and blocks (diacritics)', async ({ page }) => {
-  let hotkeyOpenLink = 'Control+o'
-  let hotkeyBack = 'Control+['
-  if (IsMac) {
-    hotkeyOpenLink = 'Meta+o'
-    hotkeyBack = 'Meta+['
-  }
+ let hotkeyOpenLink = 'Control+o'
+ let hotkeyBack = 'Control+['
+ if (IsMac) {
+   hotkeyOpenLink = 'Meta+o'
+   hotkeyBack = 'Meta+['
+ }
 
+test('Search page and blocks (diacritics)', async ({ page, block }) => {
   const rand = randomString(20)
 
   // diacritic opening test
   await createRandomPage(page)
 
-  await page.fill('textarea >> nth=0', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-1')
+  await block.mustType('[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-1', { delay: 10 })
   await page.keyboard.press(hotkeyOpenLink)
 
+  const pageTitle = page.locator('.page-title').first()
+  expect(await pageTitle.innerText()).toEqual('Einführung in die Allgemeine Sprachwissenschaft' + rand)
+
+  await page.waitForTimeout(500)
+
   // build target Page with diacritics
-  await lastBlock(page)
-  await page.type('textarea >> nth=0', 'Diacritic title test content')
+  await block.activeEditing(0)
+  await block.mustType('Diacritic title test content', { delay: 10 })
 
-  await page.keyboard.press('Enter')
-  await page.fill('textarea >> nth=0', '[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-2')
+  await block.enterNext()
+  await block.mustType('[[Einführung in die Allgemeine Sprachwissenschaft' + rand + ']] diacritic-block-2', { delay: 10 })
   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.fill('[placeholder="Search or create page"]', 'Einführung in die Allgemeine Sprachwissenschaft' + rand)
+  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)
-  const results = await page.$$('#ui__ac-inner>div')
-  expect(results.length).toEqual(3) // 2 blocks + 1 page
-  await page.keyboard.press("Escape")
+  await page.keyboard.press("Escape") // escape modal
 })
 
-async function alias_test(page: Page, page_name: string, search_kws: string[]) {
-  let hotkeyOpenLink = 'Control+o'
-  let hotkeyBack = 'Control+['
-  if (IsMac) {
-    hotkeyOpenLink = 'Meta+o'
-    hotkeyBack = 'Meta+['
-  }
+test('Search CJK', async ({ page, block }) => {
+  const rand = randomString(20)
+
+  // diacritic opening test
+  await createRandomPage(page)
+
+  await block.mustType('[[今日daytime进度条' + rand + ']] diacritic-block-1', { delay: 10 })
+  await page.keyboard.press(hotkeyOpenLink)
+
+  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
+})
+
+async function alias_test( block: Block, page: Page, page_name: string, search_kws: string[] ) {
+  await createRandomPage(page)
 
   const rand = randomString(10)
   let target_name = page_name + ' target ' + rand
@@ -58,10 +92,7 @@ async function alias_test(page: Page, page_name: string, search_kws: string[]) {
   let alias_test_content_2 = randomString(20)
   let alias_test_content_3 = randomString(20)
 
-  // shortcut opening test
-  let parent_title = await createRandomPage(page)
-
-  await page.fill('textarea >> nth=0', '[[' + target_name + ']]')
+  await page.type('textarea >> nth=0', '[[' + target_name)
   await page.keyboard.press(hotkeyOpenLink)
 
   await lastBlock(page)
@@ -71,47 +102,58 @@ async function alias_test(page: Page, page_name: string, search_kws: string[]) {
   //   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)
-  await page.press('textarea >> nth=0', 'Enter') // Enter for finishing selection
-  await page.press('textarea >> nth=0', 'Enter') // double Enter for exit property editing
-  await page.press('textarea >> nth=0', 'Enter') // double Enter for exit property editing
-  await lastBlock(page)
+  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)
   await lastBlock(page)
-  await page.keyboard.press(hotkeyBack)
+  page.keyboard.press(hotkeyBack)
 
-  await page.waitForTimeout(100) // await navigation
+  await page.waitForNavigation()
+  await block.escapeEditing()
   // create alias ref in origin Page
-  await newBlock(page)
-  await page.type('textarea >> nth=0', '[[' + alias_name)
-  await page.press('textarea >> nth=0', 'Enter') // Enter for finishing selection
+  await block.activeEditing(0)
+  await block.enterNext()
+  await page.type('textarea >> nth=0', '[[' + alias_name, {delay: 20})
+  await page.keyboard.press('Enter') // Enter for finishing selection
   await page.waitForTimeout(100)
 
-  await page.keyboard.press(hotkeyOpenLink)
-  await page.waitForTimeout(100) // await navigation
+  page.keyboard.press(hotkeyOpenLink)
+  await page.waitForNavigation()
+  await block.escapeEditing()
 
   // shortcut opening test
-  await lastBlock(page)
+  await block.activeEditing(1)
   expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_1)
 
   await enterNextBlock(page)
   await page.type('textarea >> nth=0', alias_test_content_2)
-  await page.keyboard.press(hotkeyBack)
+  page.keyboard.press(hotkeyBack)
 
-  // pressing enter opening test
-  await lastBlock(page)
+  await page.waitForNavigation()
+  await block.escapeEditing()
+  // pressing enter on alias opening test
+  await block.activeEditing(1)
   await page.press('textarea >> nth=0', 'ArrowLeft')
   await page.press('textarea >> nth=0', 'ArrowLeft')
   await page.press('textarea >> nth=0', 'ArrowLeft')
-  await page.press('textarea >> nth=0', 'Enter')
-  await lastBlock(page)
+  page.press('textarea >> nth=0', 'Enter')
+  await page.waitForNavigation()
+  await block.escapeEditing()
+  await block.activeEditing(2)
   expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_2)
   await newInnerBlock(page)
   await page.type('textarea >> nth=0', alias_test_content_3)
-  await page.keyboard.press(hotkeyBack)
-
-  // clicking opening test
-  await newBlock(page)
+  page.keyboard.press(hotkeyBack)
+  
+  await page.waitForNavigation()
+  await block.escapeEditing()
+  // clicking alias ref opening test
+  await block.activeEditing(1)
+  await block.enterNext()
   await page.waitForSelector('.page-blocks-inner .ls-block .page-ref >> nth=-1')
   await page.click('.page-blocks-inner .ls-block .page-ref >> nth=-1')
   await lastBlock(page)
@@ -125,43 +167,39 @@ async function alias_test(page: Page, page_name: string, search_kws: string[]) {
 
     await page.click('#search-button')
     await page.waitForSelector('[placeholder="Search or create page"]')
-    await page.fill('[placeholder="Search or create page"]', kw_name)
+    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(3) // page + block + alias property
+    expect(results.length).toEqual(5) // page + block + alias property + page content
 
     // test search results
     expect(await results[0].innerText()).toContain("Alias -> " + target_name)
     expect(await results[0].innerText()).toContain(alias_name)
-    expect(await results[1].innerText()).toContain(parent_title)
     expect(await results[1].innerText()).toContain("[[" + alias_name + "]]")
-    expect(await results[2].innerText()).toContain(target_name)
-    expect(await results[2].innerText()).toContain("alias:: [[" + alias_name + "]]")
+    expect(await results[2].innerText()).toContain("[[" + alias_name + "]]")
 
     // test search entering (page)
     page.keyboard.press("Enter")
     await page.waitForNavigation()
-    await page.waitForTimeout(100)
-    await lastBlock(page)
-    expect(await page.inputValue('textarea >> nth=0')).toBe(alias_test_content_3)
+    await page.waitForSelector('.ls-block span.inline')
+    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.fill('[placeholder="Search or create page"]', kw_name)
+    await page.type('[placeholder="Search or create page"]', kw_name)
     await page.waitForTimeout(500)
-    page.click(":nth-match(.menu-link, 2)")
+    page.click(":nth-match(.search-result, 3)")
     await page.waitForNavigation()
-    await page.waitForTimeout(500)
-    await lastBlock(page)
-    expect(await page.inputValue('textarea >> nth=0')).toBe("[[" + alias_name + "]]")
+    await page.waitForSelector('.selected a.page-ref')
+    expect(await page.locator('.selected a.page-ref').innerHTML()).toBe(alias_name)
     await page.keyboard.press(hotkeyBack)
   }
 
   // TODO: search clicking (alias property)
 }
 
-test.skip('page diacritic alias', async ({ page }) => {
-  await alias_test(page, "ü", ["ü", "ü", "Ü"])
+test('page diacritic alias', async ({ block, page }) => {
+  await alias_test(block, page, "ü", ["ü", "ü", "Ü"])
 })

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

@@ -1,7 +1,7 @@
 import { expect } from '@playwright/test'
 import { test } from './fixtures'
 import {
-  createRandomPage, randomInt, randomInsert, randomEditDelete, randomEditMoveUpDown, IsMac, randomString,
+  createRandomPage, randomInt, IsMac, randomString,
 } from './utils'
 
 /**
@@ -96,7 +96,8 @@ const generateRandomTest = (size: number): RandomTestStep[] => {
   return steps
 }
 
-test('Random editor operations', async ({ page, block }) => {
+// TODO: Fix test that intermittently started failing after https://github.com/logseq/logseq/pull/6945
+test.skip('Random editor operations', async ({ page, block }) => {
   const steps = generateRandomTest(20)
 
   await createRandomPage(page)
@@ -175,7 +176,5 @@ test('Random editor operations', async ({ page, block }) => {
 
     // FIXME: CHECK await block.waitForBlocks(expectedBlocks)
     await page.waitForTimeout(50)
-
   }
-
 })

+ 9 - 0
e2e-tests/sanitization.spec.ts

@@ -37,3 +37,12 @@ test('custom hiccup should not spawn any dialogs', async ({ page, block }) => {
 
   expect(true).toBeTruthy()
 })
+
+test('"is" attribute should be allowed for plugin purposes', async ({ page, block }) => {
+  await createRandomPage(page)
+
+  await page.keyboard.type('[:div {:is "custom-element" :id "custom-element-id"}]', { delay: 5 })
+  await block.enterNext()
+
+  await expect(page.locator('#custom-element-id')).toHaveAttribute('is', 'custom-element');
+})

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

@@ -12,7 +12,7 @@ test('favorite item and recent item test', async ({ page }) => {
   const fav_page_name = await createRandomPage(page)
   let favs = await page.$$('.favorite-item a')
   let previous_fav_count = favs.length
-  await page.click('.ui__dropdown-trigger')
+  await page.click('.ui__dropdown-trigger .toolbar-dots-btn')
   await page.locator("text=Add to Favorites").click()
   // click from another page
   const another_page_name = await createRandomPage(page)
@@ -24,7 +24,7 @@ test('favorite item and recent item test', async ({ page }) => {
   expect(await page.innerText(':nth-match(.recent-item a, 2)')).toBe(another_page_name)
 
   // remove fav
-  await page.click('.ui__dropdown-trigger')
+  await page.click('.ui__dropdown-trigger .toolbar-dots-btn')
   await page.locator("text=Unfavorite page").click()
   await expect(page.locator('.favorite-item a')).toHaveCount(previous_fav_count)
 
@@ -49,6 +49,7 @@ test('recent is updated #4320', async ({ page }) => {
 
   // then jump back
   await searchAndJumpToPage(page, page1)
+  await page.waitForTimeout(500)
   expect(await firstRecent.textContent()).toContain(page1)
   expect(await secondRecent.textContent()).toContain(page2)
 })

+ 6 - 6
e2e-tests/util/keyboard-events.ts

@@ -1,4 +1,4 @@
-/*** 
+/***
  * Author: Junyi Du <[email protected]>
  * References:
  * https://stackoverflow.com/questions/8892238/detect-keyboard-layout-with-javascript
@@ -243,7 +243,7 @@ export let macos_pinyin_selecting_candidate_double_left_square_bracket: Recorded
       "repeat": false,
       "isComposing": true
     },
-    "latency": 627
+    "latency": 200
   },
   {
     "event_type": "keyup",
@@ -353,7 +353,7 @@ export let macos_pinyin_selecting_candidate_double_left_square_bracket: Recorded
   {
     "event_type": "compositionend",
     "event": {},
-    "latency": 968
+    "latency": 200
   }
 ]
 
@@ -426,7 +426,7 @@ export let win10_RIME_selecting_candidate_double_left_square_bracket: RecordedEv
       "repeat": false,
       "isComposing": true
     },
-    "latency": 237
+    "latency": 200
   },
   {
     "event_type": "keyup",
@@ -461,6 +461,6 @@ export let win10_RIME_selecting_candidate_double_left_square_bracket: RecordedEv
   {
     "event_type": "compositionend",
     "event": {},
-    "latency": 1479
+    "latency": 200
   }
-]
+]

+ 8 - 4
e2e-tests/utils.ts

@@ -2,7 +2,7 @@ import { Page, Locator } from 'playwright'
 import { expect, ConsoleMessage } from '@playwright/test'
 import * as process from 'process'
 import { Block } from './types'
-import pathlib from 'path'
+import * as pathlib from 'path'
 
 export const IsMac = process.platform === 'darwin'
 export const IsLinux = process.platform === 'linux'
@@ -65,9 +65,10 @@ export async function createPage(page: Page, page_name: string) {// Click #searc
 
 export async function searchAndJumpToPage(page: Page, pageTitle: string) {
   await page.click('#search-button')
-  await page.fill('[placeholder="Search or create page"]', pageTitle)
+  await page.type('[placeholder="Search or create page"]', pageTitle)
   await page.waitForSelector(`[data-page-ref="${pageTitle}"]`, { state: 'visible' })
-  await page.click(`[data-page-ref="${pageTitle}"]`)
+  page.click(`[data-page-ref="${pageTitle}"]`)
+  await page.waitForNavigation()
   return pageTitle;
 }
 
@@ -115,6 +116,7 @@ export async function newInnerBlock(page: Page): Promise<Locator> {
   return page.locator('textarea >> nth=0')
 }
 
+// Deprecated by block.enterNext
 export async function newBlock(page: Page): Promise<Locator> {
   let blockNumber = await page.locator('.page-blocks-inner .ls-block').count()
   await lastBlock(page)
@@ -212,7 +214,9 @@ export async function loadLocalGraph(page: Page, path: string): Promise<void> {
   // close it first so it doesn't cover up the UI
   let locator = page.locator('.notification-close-button').first()
   while (await locator?.isVisible()) {
-    await locator.click()
+    try { // don't fail if unable to click (likely disappeared already)
+      await locator.click()
+    } catch (error) {}
     await page.waitForTimeout(250)
 
     expect(locator.isVisible()).resolves.toBe(false)

+ 126 - 103
e2e-tests/whiteboards.spec.ts

@@ -3,146 +3,169 @@ import { test } from './fixtures'
 import { IsMac } from './utils'
 
 test('enable whiteboards', async ({ page }) => {
-    await page.evaluate(() => {
-        window.localStorage.removeItem('ls-onboarding-whiteboard?')
-    })
-
-    await expect(page.locator('.nav-header .whiteboard')).toBeHidden()
-    await page.click('#head .toolbar-dots-btn')
-    await page.click('#head .dropdown-wrapper >> text=Settings')
-    await page.click('.settings-modal a[data-id=features]')
-    await page.click('text=Whiteboards >> .. >> .ui__toggle')
-    await page.keyboard.press('Escape')
-    await expect(page.locator('.nav-header .whiteboard')).toBeVisible()
+  await page.evaluate(() => {
+    window.localStorage.removeItem('ls-onboarding-whiteboard?')
+  })
+
+  await expect(page.locator('.nav-header .whiteboard')).toBeHidden()
+  await page.click('#head .toolbar-dots-btn')
+  await page.click('#head .dropdown-wrapper >> text=Settings')
+  await page.click('.settings-modal a[data-id=features]')
+  await page.click('text=Whiteboards >> .. >> .ui__toggle')
+  await page.keyboard.press('Escape')
+  await expect(page.locator('.nav-header .whiteboard')).toBeVisible()
 })
 
 test('create new whiteboard', async ({ page }) => {
-    await page.click('.nav-header .whiteboard')
-    await page.click('#tl-create-whiteboard')
-    await expect(page.locator('.logseq-tldraw')).toBeVisible()
+  await page.click('.nav-header .whiteboard')
+  await page.click('#tl-create-whiteboard')
+  await expect(page.locator('.logseq-tldraw')).toBeVisible()
 })
 
-test('check if the page contains the onboarding whiteboard', async ({ page }) => {
-    await expect(page.locator('.tl-text-shape-wrapper >> text=Welcome to')).toHaveCount(1)
+test('check if the page contains the onboarding whiteboard', async ({
+  page,
+}) => {
+  await expect(
+    page.locator('.tl-text-shape-wrapper >> text=Welcome to')
+  ).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('Delete')
-    await expect(page.locator('[data-type=Shape]')).toHaveCount(0)
+  if (IsMac) {
+    await page.keyboard.press('Meta+a')
+  } else {
+    await page.keyboard.press('Control+a')
+  }
+  await page.keyboard.press('Delete')
+  await expect(page.locator('[data-type=Shape]')).toHaveCount(0)
 })
 
 test('can right click title to show context menu', async ({ page }) => {
-    await page.click('.whiteboard-page-title', {
-        button: 'right',
-    })
-  
-    await expect(page.locator('#custom-context-menu')).toBeVisible()
-  
-    await page.keyboard.press('Escape')
-  
-    await expect(page.locator('#custom-context-menu')).toHaveCount(0)
-})
+  await page.click('.whiteboard-page-title', {
+    button: 'right',
+  })
 
-test('set whiteboard title', async ({ page }) => {
-    const title = "my-whiteboard"
-    // Newly created whiteboard should have a default title
-    await expect(page.locator('.whiteboard-page-title .title')).toContainText("Untitled");
-
-    await page.click('.whiteboard-page-title')
-    await page.fill('.whiteboard-page-title input', title)
-    await page.keyboard.press('Enter')
-    await expect(page.locator('.whiteboard-page-title .title')).toContainText(title);
-
-    await page.click('.whiteboard-page-title')
-    await page.fill('.whiteboard-page-title input', title + "-2")
-    await page.keyboard.press('Enter')
-
-    // Updating non-default title should pop up a confirmation dialog
-    await expect(page.locator('.ui__confirm-modal >> .headline')).toContainText(`Do you really want to change the page name to “${title}-2”?`);
-    await page.click('.ui__confirm-modal button')
-    await expect(page.locator('.whiteboard-page-title .title')).toContainText(title + "-2");
+  await expect(page.locator('#custom-context-menu')).toBeVisible()
+
+  await page.keyboard.press('Escape')
+
+  await expect(page.locator('#custom-context-menu')).toHaveCount(0)
 })
 
-test('select rectangle tool', async ({ page }) => {
-    await page.keyboard.press('8')
-    await expect(page.locator('.tl-geometry-tools-pane-anchor [title*="Rectangle"]')).toHaveAttribute('data-selected', 'true')
+test('set whiteboard title', async ({ page }) => {
+  const title = 'my-whiteboard'
+  // Newly created whiteboard should have a default title
+  await expect(page.locator('.whiteboard-page-title .title')).toContainText(
+    'Untitled'
+  )
+
+  await page.click('.whiteboard-page-title')
+  await page.fill('.whiteboard-page-title input', title)
+  await page.keyboard.press('Enter')
+  await expect(page.locator('.whiteboard-page-title .title')).toContainText(
+    title
+  )
+
+  await page.click('.whiteboard-page-title')
+  await page.fill('.whiteboard-page-title input', title + '-2')
+  await page.keyboard.press('Enter')
+
+  // Updating non-default title should pop up a confirmation dialog
+  await expect(page.locator('.ui__confirm-modal >> .headline')).toContainText(
+    `Do you really want to change the page name to “${title}-2”?`
+  )
+  await page.click('.ui__confirm-modal button')
+  await expect(page.locator('.whiteboard-page-title .title')).toContainText(
+    title + '-2'
+  )
 })
 
 test('draw a rectangle', async ({ page }) => {
-    const canvas = await page.waitForSelector('.logseq-tldraw');
-    const bounds = (await canvas.boundingBox())!;
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  const bounds = (await canvas.boundingBox())!
 
-    await page.keyboard.press('8')
+  await page.keyboard.press('r')
 
-    await page.mouse.move(bounds.x + 5, bounds.y + 5);
-    await page.mouse.down();
+  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.up();
+  await page.mouse.move(
+    bounds.x + bounds.width / 2,
+    bounds.y + bounds.height / 2
+  )
+  await page.mouse.up()
 
-    await expect(page.locator('.logseq-tldraw .tl-positioned-svg rect')).not.toHaveCount(0);
+  await expect(
+    page.locator('.logseq-tldraw .tl-positioned-svg rect')
+  ).not.toHaveCount(0)
 })
 
 test('zoom in', async ({ page }) => {
-    await page.click('#tl-zoom-in')
-    await expect(page.locator('#tl-zoom')).toContainText('125%');
+  await page.click('#tl-zoom-in')
+  await expect(page.locator('#tl-zoom')).toContainText('125%')
 })
 
 test('zoom out', async ({ page }) => {
-    await page.click('#tl-zoom-out')
-    await expect(page.locator('#tl-zoom')).toContainText('100%');
+  await page.click('#tl-zoom-out')
+  await expect(page.locator('#tl-zoom')).toContainText('100%')
 })
 
 test('open context menu', async ({ page }) => {
-    await page.locator('.logseq-tldraw').click({ button: "right" })
-    await expect(page.locator('.tl-context-menu')).toBeVisible()
+  await page.locator('.logseq-tldraw').click({ button: 'right' })
+  await expect(page.locator('.tl-context-menu')).toBeVisible()
 })
 
 test('close context menu on esc', async ({ page }) => {
-    await page.keyboard.press('Escape')
-    await expect(page.locator('.tl-context-menu')).toBeHidden()
+  await page.keyboard.press('Escape')
+  await expect(page.locator('.tl-context-menu')).toBeHidden()
 })
 
 test('quick add another whiteboard', async ({ page }) => {
-    // create a new board first
-    await page.click('.nav-header .whiteboard')
-    await page.click('#tl-create-whiteboard')
-    
-    await page.click('.whiteboard-page-title')
-    await page.fill('.whiteboard-page-title input', "my-whiteboard-3")
-    await page.keyboard.press('Enter')
-    
-    const canvas = await page.waitForSelector('.logseq-tldraw');
-    await canvas.dblclick({
-        position: {
-            x: 100,
-            y: 100
-        }
-    })
-
-    const quickAdd$ = page.locator('.tl-quick-search')
-    await expect(quickAdd$).toBeVisible()
-
-    await page.fill('.tl-quick-search input', 'my-whiteboard')
-    await quickAdd$.locator('.tl-quick-search-option >> text=my-whiteboard-2').first().click()
-
-    await expect(quickAdd$).toBeHidden()
-    await expect(page.locator('.tl-logseq-portal-container >> text=my-whiteboard-2')).toBeVisible()
+  // create a new board first
+  await page.click('.nav-header .whiteboard')
+  await page.click('#tl-create-whiteboard')
+
+  await page.click('.whiteboard-page-title')
+  await page.fill('.whiteboard-page-title input', 'my-whiteboard-3')
+  await page.keyboard.press('Enter')
+
+  const canvas = await page.waitForSelector('.logseq-tldraw')
+  await canvas.dblclick({
+    position: {
+      x: 100,
+      y: 100,
+    },
+  })
+
+  const quickAdd$ = page.locator('.tl-quick-search')
+  await expect(quickAdd$).toBeVisible()
+
+  await page.fill('.tl-quick-search input', 'my-whiteboard')
+  await quickAdd$
+    .locator('.tl-quick-search-option >> text=my-whiteboard-2')
+    .first()
+    .click()
+
+  await expect(quickAdd$).toBeHidden()
+  await expect(
+    page.locator('.tl-logseq-portal-container >> text=my-whiteboard-2')
+  ).toBeVisible()
 })
 
 test('go to another board and check reference', async ({ page }) => {
-    await page.locator('.tl-logseq-portal-container >> text=my-whiteboard-2').click()
-    await expect(page.locator('.whiteboard-page-title .title')).toContainText("my-whiteboard-2");
-  
-    const pageRefCount$ = page.locator('.whiteboard-page-refs-count')
-    await expect(pageRefCount$.locator('.open-page-ref-link')).toContainText('1')
-
-    await pageRefCount$.click()
-    await expect(page.locator('.references-blocks')).toBeVisible()
-    await expect(page.locator('.references-blocks >> .page-ref >> text=my-whiteboard-3')).toBeVisible()
+  await page
+    .locator('.tl-logseq-portal-container >> text=my-whiteboard-2')
+    .click()
+  await expect(page.locator('.whiteboard-page-title .title')).toContainText(
+    'my-whiteboard-2'
+  )
+
+  const pageRefCount$ = page.locator('.whiteboard-page-refs-count')
+  await expect(pageRefCount$.locator('.open-page-ref-link')).toContainText('1')
+
+  await pageRefCount$.click()
+  await expect(page.locator('.references-blocks')).toBeVisible()
+  await expect(
+    page.locator('.references-blocks >> .page-ref >> text=my-whiteboard-3')
+  ).toBeVisible()
 })

+ 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.11;
+				MARKETING_VERSION = 0.8.13;
 				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.11;
+				MARKETING_VERSION = 0.8.13;
 				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.11;
+				MARKETING_VERSION = 0.8.13;
 				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.11;
+				MARKETING_VERSION = 0.8.13;
 				MTL_FAST_MATH = YES;
 				PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
 				PRODUCT_NAME = "$(TARGET_NAME)";

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

@@ -28,6 +28,7 @@ public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
             self.baseUrl = url
             self.watcher = PollingWatcher(at: url)
             self.watcher?.delegate = self
+            self.watcher?.start()
 
             call.resolve(["ok": true])
 
@@ -167,15 +168,19 @@ public class PollingWatcher {
 
     public init?(at: URL) {
         url = at
-
+    }
+    
+    public func start() {
+        
+        self.tick(notify: false)
+        
         let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
         timer = DispatchSource.makeTimerSource(queue: queue)
         timer!.setEventHandler(qos: .background, flags: []) { [weak self] in
-            self?.tick()
+            self?.tick(notify: true)
         }
         timer!.schedule(deadline: .now())
         timer!.resume()
-
     }
 
     deinit {
@@ -187,7 +192,7 @@ public class PollingWatcher {
         timer = nil
     }
 
-    private func tick() {
+    private func tick(notify: Bool) {
         // let startTime = DispatchTime.now()
 
         if let enumerator = FileManager.default.enumerator(
@@ -223,7 +228,11 @@ public class PollingWatcher {
                 }
             }
 
-            self.updateMetaDb(with: newMetaDb)
+            if notify {
+                self.updateMetaDb(with: newMetaDb)
+            } else {
+                self.metaDb = newMetaDb
+            }
         }
 
         // let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds

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

@@ -107,5 +107,7 @@
 	<true/>
 	<key>UIViewControllerBasedStatusBarAppearance</key>
 	<true/>
+	<key>ITSAppUsesNonExemptEncryption</key>
+	<false/>
 </dict>
 </plist>

+ 1 - 0
ios/App/Podfile

@@ -21,6 +21,7 @@ def capacitor_pods
   pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
   pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
   pod 'CapawesomeCapacitorBackgroundTask', :path => '../../node_modules/@capawesome/capacitor-background-task'
+  pod 'HugotomaziCapacitorNavigationBar', :path => '../../node_modules/@hugotomazi/capacitor-navigation-bar'
   pod 'LogseqCapacitorFileSync', :path => '../../node_modules/@logseq/capacitor-file-sync'
   pod 'CapacitorVoiceRecorder', :path => '../../node_modules/capacitor-voice-recorder'
   pod 'SendIntent', :path => '../../node_modules/send-intent'

+ 8 - 0
ios/App/fastlane/Appfile

@@ -0,0 +1,8 @@
+app_identifier("com.logseq.logseq") # The bundle identifier of your app
+apple_id("[email protected]") # Your Apple Developer Portal username
+
+itc_team_id("123783177") # App Store Connect Team ID
+team_id("K378MFWK59") # Developer Portal Team ID
+
+# For more information about the Appfile, see:
+#     https://docs.fastlane.tools/advanced/#appfile

+ 53 - 0
ios/App/fastlane/Fastfile

@@ -0,0 +1,53 @@
+# This file contains the fastlane.tools configuration
+# You can find the documentation at https://docs.fastlane.tools
+#
+# For a list of all available actions, check out
+#
+#     https://docs.fastlane.tools/actions
+#
+# For a list of all available plugins, check out
+#
+#     https://docs.fastlane.tools/plugins/available-plugins
+#
+
+# Uncomment the line if you want fastlane to automatically update itself
+# update_fastlane
+
+default_platform(:ios)
+
+platform :ios do
+  desc "Push a new beta build to TestFlight"
+  lane :beta do
+    setup_ci
+
+    app_store_connect_api_key(
+      key_id: ENV["APP_STORE_CONNECT_API_KEY_KEY_ID"],
+      issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"],
+      key_filepath: ENV["APP_STORE_CONNECT_API_KEY_KEY_FILEPATH"],
+    )
+
+    sync_code_signing(type: "appstore", readonly: true)
+
+    build_number = increment_build_number(
+      xcodeproj: "App.xcodeproj",
+      build_number: latest_testflight_build_number + 1,
+    )
+
+    # Ref: https://docs.fastlane.tools/advanced/fastlane/#directory-behavior
+    sh("../../../scripts/patch-xcode-project.sh")
+
+    build_app(
+      workspace: "App.xcworkspace",
+      destination: "generic/platform=iOS",
+      scheme: "Logseq",
+      configuration: "Release",
+    )
+
+    upload_to_testflight(
+      skip_submission: true,
+      skip_waiting_for_build_processing: true,
+    )
+
+    slack(message: "App Build (#{build_number}) successfully uploaded to TestFlight 🎉!")
+  end
+end

+ 13 - 0
ios/App/fastlane/Matchfile

@@ -0,0 +1,13 @@
+git_url("https://github.com/logseq/certificates.git")
+
+storage_mode("git")
+
+type("appstore") # The default type, can be: appstore, adhoc, enterprise or development
+
+app_identifier(["com.logseq.logseq", "com.logseq.logseq.ShareViewController"])
+# username("[email protected]") # Your Apple Developer Portal username
+
+# For all available options run `fastlane match --help`
+# Remove the # in the beginning of the line to enable the other options
+
+# The docs are available on https://docs.fastlane.tools/actions/match

+ 32 - 0
ios/App/fastlane/README.md

@@ -0,0 +1,32 @@
+fastlane documentation
+----
+
+# Installation
+
+Make sure you have the latest version of the Xcode command line tools installed:
+
+```sh
+xcode-select --install
+```
+
+For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
+
+# Available Actions
+
+## iOS
+
+### ios beta
+
+```sh
+[bundle exec] fastlane ios beta
+```
+
+Push a new beta build to TestFlight
+
+----
+
+This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
+
+More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
+
+The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

+ 1 - 0
libs/.npmignore

@@ -1,3 +1,4 @@
 src/
 webpack.*
 .DS_Store
+docs/

+ 30 - 0
libs/CHANGELOG.md

@@ -0,0 +1,30 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+## [0.0.11]
+
+### Added
+
+- All configurations of current graph.
+  `App.getCurrentGraphConfigs: () => Promise<any>`
+- All favorite pages list of current graph.
+  `App.getCurrentGraphFavorites: () => Promise<Array<string> | null>`
+- All recent pages list of current graph.
+  `App.getCurrentGraphRecent: () => Promise<Array<string> | null>`
+- Clear right sidebar blocks.
+  `App.clearRightSidebarBlocks: (opts?: { close: boolean }) => void`
+- Support register `CodeMirror` enhancer. _#Experiment feature_
+  `Experiments.registerExtensionsEnhancer<T = any>(type: 'katex' | 'codemirror', enhancer: (v: T) => Promise<any>)`
+- Support hooks for app search service. _#Alpha stage_
+  `App.registerSearchService<T extends IPluginSearchServiceHooks>(s: T): void`
+- Support `focus` option for `App.insertBlock`. Credit
+  to [[[tennox](https://github.com/tennox)]] [#PR](https://github.com/logseq/logseq/commit/4217057a44de65e5c64be37857af2fb4e9534b24)
+
+### Fixed
+
+- Adjust build script to be compatible for `shadow-cljs` bundler.
+  > How to set up a clojurescript project with shadow-cljs?
+  > https://github.com/rlhk/logseq-url-plus/blob/main/doc/dev-notes.md

+ 10 - 0
libs/babel.config.json

@@ -0,0 +1,10 @@
+{
+  "presets": [
+    [
+      "@babel/preset-env",
+      {
+        "targets": "chrome 72"
+      }
+    ]
+  ]
+}

+ 9 - 2
libs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@logseq/libs",
-  "version": "0.0.10",
+  "version": "0.0.11",
   "description": "Logseq SDK libraries",
   "main": "dist/lsplugin.user.js",
   "typings": "index.d.ts",
@@ -12,7 +12,8 @@
     "dev:core": "npm run build:core -- --mode development --watch",
     "build": "tsc && rm dist/*.js && npm run build:user",
     "lint": "prettier --check \"src/**/*.{ts, js}\"",
-    "fix": "prettier --write \"src/**/*.{ts, js}\""
+    "fix": "prettier --write \"src/**/*.{ts, js}\"",
+    "build:docs": "typedoc --plugin typedoc-plugin-lsp-docs src/LSPlugin.user.ts && typedoc --json docs/out.json ./src/LSPlugin.user.ts"
   },
   "dependencies": {
     "csstype": "3.1.0",
@@ -25,12 +26,18 @@
     "snake-case": "3.0.4"
   },
   "devDependencies": {
+    "@babel/core": "^7.20.2",
+    "@babel/preset-env": "^7.20.2",
     "@types/debug": "^4.1.5",
     "@types/dompurify": "2.3.3",
     "@types/lodash-es": "4.17.6",
+    "babel-loader": "^9.1.0",
     "prettier": "^2.6.2",
     "prettier-config-standard": "^5.0.0",
+    "terser-webpack-plugin": "^5.3.6",
     "ts-loader": "9.3.0",
+    "typedoc": "^0.23.17",
+    "typedoc-plugin-lsp-docs": "^0.0.1",
     "typescript": "4.7.3",
     "webpack": "5.73.0",
     "webpack-bundle-analyzer": "4.5.0",

+ 1 - 0
libs/src/LSPlugin.caller.ts

@@ -279,6 +279,7 @@ class LSPluginCaller extends EventEmitter {
             debug(`[user -> *host] `, type, payload)
 
             this._pluginLocal?.emit(type, payload || {})
+            this._pluginLocal?.caller.emit(type, payload || {})
           })
 
           this._call = async (...args: any) => {

+ 13 - 4
libs/src/LSPlugin.core.ts

@@ -139,7 +139,12 @@ class PluginLogger extends EventEmitter<'change'> {
     super()
   }
 
-  write(type: string, payload: any[]) {
+  write(type: string, payload: any[], inConsole?: boolean) {
+    if (payload?.length && (true === payload[payload.length - 1])) {
+      inConsole = true
+      payload.pop()
+    }
+
     const msg = payload.reduce((ac, it) => {
       if (it && it instanceof Error) {
         ac += `${it.message} ${it.stack}`
@@ -150,6 +155,11 @@ class PluginLogger extends EventEmitter<'change'> {
     }, `[${this._tag}][${new Date().toLocaleTimeString()}] `)
 
     this._logs.push([type, msg])
+
+    if (inConsole) {
+      console?.['ERROR' === type ? 'error' : 'debug'](`${type}: ${msg}`)
+    }
+
     this.emit('change')
   }
 
@@ -907,9 +917,9 @@ class PluginLocal extends EventEmitter<'loaded'
 
       this._dispose(cleanInjectedScripts.bind(this))
     } catch (e) {
-      console.error('[Load Plugin Error] ', e)
-      this.logger?.error(e)
+      this.logger?.error('[Load Plugin]', e, true)
 
+      this.dispose().catch(null)
       this._status = PluginLocalLoadStatus.ERROR
       this._loadErr = e
     } finally {
@@ -1329,7 +1339,6 @@ class LSPluginCore
           }
         }
 
-
         pluginLocal.settings?.on('change', (a) => {
           this.emit('settings-changed', pluginLocal.id, a)
           pluginLocal.caller?.callUserModel(LSPMSG_SETTINGS, { payload: a })

+ 45 - 3
libs/src/LSPlugin.ts

@@ -289,6 +289,34 @@ export type ExternalCommandType =
 
 export type UserProxyTags = 'app' | 'editor' | 'db' | 'git' | 'ui' | 'assets'
 
+export type SearchIndiceInitStatus = boolean
+export type SearchBlockItem = { id: EntityID, uuid: BlockIdentity, content: string, page: EntityID }
+export type SearchPageItem = string
+export type SearchFileItem = string
+
+export interface IPluginSearchServiceHooks {
+  name: string
+  options?: Record<string, any>
+
+  onQuery: (
+    graph: string,
+    key: string,
+    opts: Partial<{ limit: number }>
+  ) =>
+    Promise<{
+      graph: string,
+      key: string,
+      blocks?: Array<Partial<SearchBlockItem>>,
+      pages?: Array<SearchPageItem>,
+      files?: Array<SearchFileItem>
+    }>
+
+  onIndiceInit: (graph: string) => Promise<SearchIndiceInitStatus>
+  onIndiceReset: (graph: string) => Promise<void>
+  onBlocksChanged: (graph: string, changes: { added: Array<SearchBlockItem>, removed: Array<BlockEntity> }) => Promise<void>
+  onGraphRemoved: (graph: string, opts?: {}) => Promise<any>
+}
+
 /**
  * App level APIs
  */
@@ -302,6 +330,9 @@ export interface IAppProxy {
   getUserInfo: () => Promise<AppUserInfo | null>
   getUserConfigs: () => Promise<AppUserConfigs>
 
+  // services
+  registerSearchService<T extends IPluginSearchServiceHooks>(s: T): void
+
   // commands
   registerCommand: (
     type: string,
@@ -352,6 +383,7 @@ export interface IAppProxy {
    * @param path
    */
   getStateFromStore: <T = any>(path: string | Array<string>) => Promise<T>
+  setStateFromStore: (path: string | Array<string>, value: any) => Promise<void>
 
   // native
   relaunch: () => Promise<void>
@@ -367,6 +399,9 @@ export interface IAppProxy {
 
   // graph
   getCurrentGraph: () => Promise<AppGraphInfo | null>
+  getCurrentGraphConfigs: () => Promise<any>
+  getCurrentGraphFavorites: () => Promise<Array<string> | null>
+  getCurrentGraphRecent: () => Promise<Array<string> | null>
 
   // router
   pushState: (
@@ -403,6 +438,7 @@ export interface IAppProxy {
   setFullScreen: (flag: boolean | 'toggle') => void
   setLeftSidebarVisible: (flag: boolean | 'toggle') => void
   setRightSidebarVisible: (flag: boolean | 'toggle') => void
+  clearRightSidebarBlocks: (opts?: { close: boolean }) => void
 
   registerUIItem: (
     type: 'toolbar' | 'pagebar',
@@ -592,15 +628,21 @@ export interface IEditorProxy extends Record<string, any> {
       before: boolean
       sibling: boolean
       isPageBlock: boolean
+      focus: boolean
       customUUID: string
       properties: {}
     }>
   ) => Promise<BlockEntity | null>
 
+  /**
+   * @example https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-reddit-hot-news
+   * 
+   * `keepUUID` will allow you to set a custom UUID for blocks by setting their properties.id
+   */
   insertBatchBlock: (
     srcBlock: BlockIdentity,
     batch: IBatchBlock | Array<IBatchBlock>,
-    opts?: Partial<{ before: boolean; sibling: boolean }>
+    opts?: Partial<{ before: boolean; sibling: boolean, keepUUID: boolean }>
   ) => Promise<Array<BlockEntity> | null>
 
   updateBlock: (
@@ -804,14 +846,14 @@ export interface IAssetsProxy {
    * @added 0.0.2
    * @param exts
    */
-  listFilesOfCurrentGraph(exts?: string | string[]): Promise<{
+  listFilesOfCurrentGraph(exts?: string | string[]): Promise<Array<{
     path: string
     size: number
     accessTime: number
     modifiedTime: number
     changeTime: number
     birthTime: number
-  }>
+  }>>
 
   /**
    * @example https://github.com/logseq/logseq/pull/6488

+ 25 - 3
libs/src/LSPlugin.user.ts

@@ -33,7 +33,7 @@ import {
   BlockEntity,
   IDatom,
   IAssetsProxy,
-  AppInfo,
+  AppInfo, IPluginSearchServiceHooks,
 } from './LSPlugin'
 import Debug from 'debug'
 import * as CSS from 'csstype'
@@ -41,6 +41,7 @@ import EventEmitter from 'eventemitter3'
 import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
 import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
 import { LSPluginRequest } from './modules/LSPlugin.Request'
+import { LSPluginSearchService } from './modules/LSPlugin.Search'
 
 declare global {
   interface Window {
@@ -86,13 +87,15 @@ function registerSimpleCommand(
     method: 'register-plugin-simple-command',
     args: [
       this.baseInfo.id,
-      [{ key, label, type, desc, keybinding, extras}, ['editor/hook', eventKey]],
+      [{ key, label, type, desc, keybinding, extras }, ['editor/hook', eventKey]],
       palette,
     ],
   })
 }
 
 let _appBaseInfo: AppInfo = null
+let _searchServices: Map<string, LSPluginSearchService> = new Map()
+
 const app: Partial<IAppProxy> = {
   async getInfo(
     this: LSPluginUser,
@@ -106,6 +109,17 @@ const app: Partial<IAppProxy> = {
 
   registerCommand: registerSimpleCommand,
 
+  registerSearchService<T extends IPluginSearchServiceHooks>(
+    this: LSPluginUser,
+    s: T
+  ) {
+    if (_searchServices.has(s.name)) {
+      throw new Error(`SearchService: #${s.name} has registered!`)
+    }
+
+    _searchServices.set(s.name, new LSPluginSearchService(this, s))
+  },
+
   registerCommandPalette(
     opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
     action: SimpleCommandCallback
@@ -356,7 +370,11 @@ type uiState = {
 const KEY_MAIN_UI = 0
 
 /**
- * User plugin instance
+ * User plugin instance from global namespace `logseq`.
+ * @example
+ * ```ts
+ * logseq.UI.showMsg('Hello, Logseq')
+ * ```
  * @public
  */
 export class LSPluginUser
@@ -420,6 +438,7 @@ export class LSPluginUser
     })
   }
 
+  // Life related
   async ready(model?: any, callback?: any) {
     if (this._connected) return
 
@@ -495,6 +514,7 @@ export class LSPluginUser
     return this
   }
 
+  // Settings related
   useSettingsSchema(schema: Array<SettingSchemaDesc>) {
     if (this.connected) {
       this.caller.call('settings:schema', {
@@ -526,6 +546,7 @@ export class LSPluginUser
     this.caller.call('settings:visible:changed', { visible: false })
   }
 
+  // UI related
   setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
     this.caller.call('main-ui:attrs', attrs)
   }
@@ -566,6 +587,7 @@ export class LSPluginUser
     }
   }
 
+  // Getters
   get version(): string {
     return this._version
   }

+ 1 - 1
libs/src/modules/LSPlugin.Experiments.ts

@@ -60,7 +60,7 @@ export class LSPluginExperiments {
   }
 
   registerExtensionsEnhancer<T = any>(
-    type: 'katex',
+    type: 'katex' | 'codemirror',
     enhancer: (v: T) => Promise<any>
   ) {
     const host = this.ensureHostScope()

+ 82 - 0
libs/src/modules/LSPlugin.Search.ts

@@ -0,0 +1,82 @@
+import { IPluginSearchServiceHooks } from '../LSPlugin'
+import { LSPluginUser } from '../LSPlugin.user'
+import { isArray, isFunction, mapKeys } from 'lodash-es'
+
+export class LSPluginSearchService {
+
+  /**
+   * @param ctx
+   * @param serviceHooks
+   */
+  constructor(
+    private ctx: LSPluginUser,
+    private serviceHooks: IPluginSearchServiceHooks
+  ) {
+    ctx._execCallableAPI(
+      'register-search-service',
+      ctx.baseInfo.id,
+      serviceHooks.name,
+      serviceHooks.options
+    )
+
+    // hook events TODO: remove listeners
+    const wrapHookEvent = (k) => `service:search:${k}:${serviceHooks.name}`
+
+    Object.entries(
+      {
+        query: {
+          f: 'onQuery', args: ['graph', 'q', true], reply: true,
+          transformOutput: (data: any) => {
+            // TODO: transform keys?
+            if (isArray(data?.blocks)) {
+              data.blocks = data.blocks.map(it => {
+                return it && mapKeys(it, (_, k) => `block/${k}`)
+              })
+            }
+
+            return data
+          }
+        },
+        rebuildBlocksIndice: { f: 'onIndiceInit', args: ['graph', 'blocks'] },
+        transactBlocks: { f: 'onBlocksChanged', args: ['graph', 'data'] },
+        truncateBlocks: { f: 'onIndiceReset', args: ['graph'] },
+        removeDb: { f: 'onGraph', args: ['graph'] }
+      }
+    ).forEach(
+      ([k, v]) => {
+        const hookEvent = wrapHookEvent(k)
+        ctx.caller.on(hookEvent, async (payload: any) => {
+          if (isFunction(serviceHooks?.[v.f])) {
+            let ret = null
+
+            try {
+              ret = await serviceHooks[v.f].apply(
+                serviceHooks, (v.args || []).map((prop: any) => {
+                  if (!payload) return
+                  if (prop === true) return payload
+                  if (payload.hasOwnProperty(prop)) {
+                    const ret = payload[prop]
+                    delete payload[prop]
+                    return ret
+                  }
+                })
+              )
+
+              if (v.transformOutput) {
+                ret = v.transformOutput(ret)
+              }
+            } catch (e) {
+              console.error('[SearchService] ', e)
+              ret = e
+            } finally {
+              if (v.reply) {
+                ctx.caller.call(
+                  `${hookEvent}:reply`, ret
+                )
+              }
+            }
+          }
+        })
+      })
+  }
+}

+ 16 - 2
libs/webpack.config.js

@@ -2,6 +2,7 @@ const pkg = require('./package.json')
 const path = require('path')
 const webpack = require('webpack')
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
+const TerserPlugin = require('terser-webpack-plugin')
 
 module.exports = {
   entry: './src/LSPlugin.user.ts',
@@ -9,14 +10,27 @@ module.exports = {
     rules: [
       {
         test: /\.tsx?$/,
-        use: 'ts-loader',
+        use: [
+          {
+            loader: 'babel-loader'
+          },
+          {
+            loader: 'ts-loader'
+          }
+        ],
         exclude: /node_modules/,
-      },
+      }
     ],
   },
   resolve: {
     extensions: ['.tsx', '.ts', '.js'],
   },
+  optimization: {
+    minimize: true,
+    minimizer: [
+      new TerserPlugin()
+    ]
+  },
   plugins: [
     new webpack.ProvidePlugin({
       process: 'process/browser',

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 956 - 4
libs/yarn.lock


+ 7 - 14
package.json

@@ -4,9 +4,9 @@
     "private": true,
     "main": "static/electron.js",
     "devDependencies": {
-        "@axe-core/playwright": "^4.4.4",
+        "@axe-core/playwright": "=4.4.4",
         "@capacitor/cli": "^4.0.0",
-        "@playwright/test": "^1.24.2",
+        "@playwright/test": "=1.25.2",
         "@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.24.2",
+        "playwright": "=1.25.2",
         "postcss": "8.4.17",
         "postcss-cli": "10.0.0",
         "postcss-import": "15.0.0",
@@ -39,7 +39,6 @@
         "app-watch": "run-p gulp:watch cljs:app-watch",
         "release": "run-s gulp:build cljs:release",
         "release-app": "run-s gulp:build cljs:release-app",
-        "release-android-app": "run-s gulp:build cljs:release-android-app",
         "dev-release-app": "run-s gulp:build cljs:dev-release-app",
         "dev-electron-app": "gulp electron",
         "release-electron": "run-s gulp:build && gulp electronMaker",
@@ -61,7 +60,6 @@
         "cljs:release": "clojure -M:cljs release app publishing electron",
         "cljs:release-electron": "clojure -M:cljs release app electron --debug && clojure -M:cljs release publishing",
         "cljs:release-app": "clojure -M:cljs release app --config-merge \"{:compiler-options {:output-feature-set :es6}}\"",
-        "cljs:release-android-app": "clojure -M:cljs release app --config-merge \"{:compiler-options {:output-feature-set :es6}}\"",
         "cljs:release-publishing": "clojure -M:cljs release publishing",
         "cljs:test": "clojure -M:test compile test",
         "cljs:run-test": "node static/tests.js",
@@ -90,14 +88,13 @@
         "@capacitor/status-bar": "^4.0.0",
         "@capawesome/capacitor-background-task": "^2.0.0",
         "@excalidraw/excalidraw": "0.12.0",
-        "@kanru/rage-wasm": "^0.3.0",
-        "@logseq/capacitor-file-sync": "0.0.13",
+        "@hugotomazi/capacitor-navigation-bar": "^2.0.0",
+        "@logseq/capacitor-file-sync": "0.0.15",
         "@logseq/react-tweet-embed": "1.3.1-1",
         "@sentry/react": "^6.18.2",
         "@sentry/tracing": "^6.18.2",
         "@tabler/icons": "^1.96.0",
         "@tippyjs/react": "4.2.5",
-        "aes-js": "3.1.2",
         "bignumber.js": "^9.0.2",
         "capacitor-voice-recorder": "4.0.0",
         "check-password-strength": "2.0.7",
@@ -107,19 +104,17 @@
         "d3-force": "3.0.0",
         "diff": "5.0.0",
         "dompurify": "2.4.0",
-        "electron": "19.0.12",
+        "electron": "19.1.8",
         "electron-dl": "3.3.0",
         "fs": "0.0.1-security",
         "fs-extra": "9.1.0",
         "fuse.js": "6.4.6",
         "grapheme-splitter": "1.0.4",
         "graphology": "0.20.0",
-        "gulp-cached": "1.1.1",
         "highlight.js": "10.4.1",
         "ignore": "5.1.8",
-        "is-svg": "4.3.0",
         "jszip": "3.7.0",
-        "mldoc": "^1.5.0",
+        "mldoc": "^1.5.1",
         "path": "0.12.7",
         "path-complete-extname": "1.0.0",
         "pixi-graph-fork": "0.2.0",
@@ -128,8 +123,6 @@
         "react": "17.0.2",
         "react-dom": "17.0.2",
         "react-grid-layout": "0.16.6",
-        "react-icon-base": "^2.1.2",
-        "react-icons": "2.2.7",
         "react-intersection-observer": "^9.3.5",
         "react-resize-context": "3.0.0",
         "react-textarea-autosize": "8.3.3",

+ 0 - 12
public/index.html

@@ -53,19 +53,7 @@
 <script defer src="/static/js/main.js"></script>
 <script defer src="/static/js/tabler.min.js"></script>
 <script defer src="/static/js/code-editor.js"></script>
-<script defer src="/static/js/age-encryption.js"></script>
 <script defer src="/static/js/tldraw.js"></script>
 <script defer src="/static/js/excalidraw.js"></script>
-<script>
-  /*!
- * swiped-events.js - v1.1.6
- * Pure JavaScript swipe events
- * https://github.com/john-doherty/swiped-events
- * @inspiration https://stackoverflow.com/questions/16348031/disable-scrolling-when-touch-moving-certain-element
- * @author John Doherty <www.johndoherty.info>
- * @license MIT
- */
-!function(t,e){"use strict";"function"!=typeof t.CustomEvent&&(t.CustomEvent=function(t,n){n=n||{bubbles:!1,cancelable:!1,detail:void 0};var a=e.createEvent("CustomEvent");return a.initCustomEvent(t,n.bubbles,n.cancelable,n.detail),a},t.CustomEvent.prototype=t.Event.prototype),e.addEventListener("touchstart",function(t){if("true"===t.target.getAttribute("data-swipe-ignore"))return;s=t.target,r=Date.now(),n=t.touches[0].clientX,a=t.touches[0].clientY,u=0,i=0},!1),e.addEventListener("touchmove",function(t){if(!n||!a)return;var e=t.touches[0].clientX,r=t.touches[0].clientY;u=n-e,i=a-r},!1),e.addEventListener("touchend",function(t){if(s!==t.target)return;var e=parseInt(l(s,"data-swipe-threshold","20"),10),o=parseInt(l(s,"data-swipe-timeout","500"),10),c=Date.now()-r,d="",p=t.changedTouches||t.touches||[];Math.abs(u)>Math.abs(i)?Math.abs(u)>e&&c<o&&(d=u>0?"swiped-left":"swiped-right"):Math.abs(i)>e&&c<o&&(d=i>0?"swiped-up":"swiped-down");if(""!==d){var b={dir:d.replace(/swiped-/,""),touchType:(p[0]||{}).touchType||"direct",xStart:parseInt(n,10),xEnd:parseInt((p[0]||{}).clientX||-1,10),yStart:parseInt(a,10),yEnd:parseInt((p[0]||{}).clientY||-1,10)};s.dispatchEvent(new CustomEvent("swiped",{bubbles:!0,cancelable:!0,detail:b})),s.dispatchEvent(new CustomEvent(d,{bubbles:!0,cancelable:!0,detail:b}))}n=null,a=null,r=null},!1);var n=null,a=null,u=null,i=null,r=null,s=null;function l(t,n,a){for(;t&&t!==e.documentElement;){var u=t.getAttribute(n);if(u)return u;t=t.parentNode}return a}}(window,document);
-</script>
 </body>
 </html>

+ 26 - 20
resources/css/common.css

@@ -12,7 +12,7 @@
   --ls-headbar-height: 3rem;
   --ls-headbar-inner-top-padding: 0px;
   --ls-left-sidebar-width: 246px;
-  --ls-left-sidebar-sm-width: 70%;
+  --ls-left-sidebar-sm-width: 74vw;
   --ls-left-sidebar-nav-btn-size: 38px;
   --ls-error-color: var(--color-red-500);
   --ls-warning-color: var(--color-orange-500);
@@ -48,12 +48,6 @@ html[data-theme='dark'] {
   --ls-block-properties-background-color: #06323e;
   --ls-page-properties-background-color: #02171d;
   --ls-block-ref-link-text-color: #1a6376;
-  --ls-search-background-color: linear-gradient(
-    to right,
-    #021c23 0,
-    #021b21 200px,
-    #002b36 100%
-  );
   --ls-border-color: #0e5263;
   --ls-secondary-border-color: #126277;
   --ls-tertiary-border-color: rgba(0, 2, 0, 0.10);
@@ -88,8 +82,9 @@ html[data-theme='dark'] {
   --ls-scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.2);
   --ls-cloze-text-color: #8fbc8f;
   --ls-icon-color: var(--ls-link-text-color);
-  --ls-search-icon-color: var(--ls-link-text-color);
-  --ls-a-chosen-bg: var(--ls-secondary-background-color);
+  --ls-search-icon-color: var(--ls-primary-text-color);
+  --ls-search-icon-hover-color: var(--ls-secondary-text-color);
+  --ls-a-chosen-bg: var(--ls-quaternary-background-color);
   --ls-pie-bg-color: #01303b;
   --ls-pie-fg-color: #0b5869;
   --ls-highlight-color-gray: var(--color-gray-900);
@@ -125,13 +120,12 @@ html[data-theme='light'] {
   --ls-secondary-background-color: #f7f7f7;
   --ls-tertiary-background-color: #eaeaea;
   --ls-quaternary-background-color: #dcdcdc;
-  --ls-table-tr-even-background-color: #f7f7f7;
+  --ls-table-tr-even-background-color: var(--ls-secondary-background-color);
   --ls-active-primary-color: rgb(0, 105, 182);
   --ls-active-secondary-color: #00477c;
-  --ls-block-properties-background-color: #f7f7f7;
-  --ls-page-properties-background-color: #f7f7f7;
+  --ls-block-properties-background-color: var(--ls-secondary-background-color);
+  --ls-page-properties-background-color: var(--ls-secondary-background-color);
   --ls-block-ref-link-text-color: #d8e1e8;
-  --ls-search-background-color: var(--ls-primary-background-color);
   --ls-border-color: #ccc;
   --ls-secondary-border-color: #e2e2e2;
   --ls-tertiary-border-color: rgba(200, 200, 200, 0.30);
@@ -142,8 +136,8 @@ html[data-theme='light'] {
   --ls-title-text-color: var(--ls-header-button-background);
   --ls-link-text-color: #106ba3;
   --ls-link-text-hover-color: #1a537c;
-  --ls-link-ref-text-color: #106ba3;
-  --ls-link-ref-text-hover-color: #1a537c;
+  --ls-link-ref-text-color: var(--ls-link-text-color);
+  --ls-link-ref-text-hover-color: var(--ls-link-text-hover-color);
   --ls-tag-text-color: var(--ls-link-ref-text-color);
   --ls-tag-text-hover-color: var(--ls-link-ref-text-hover-color);
   --ls-slide-background-color: #fff;
@@ -151,6 +145,7 @@ html[data-theme='light'] {
   --ls-block-bullet-color: rgba(67, 63, 56, 0.25);
   --ls-block-highlight-color: #c0e6fd;
   --ls-selection-background-color: #e4f2ff;
+  --ls-selection-text-color: var(--ls-secondary-text-color);
   --ls-page-checkbox-color: #9dbbd8;
   --ls-page-checkbox-border-color: var(--ls-page-checkbox-color);
   --ls-page-blockquote-color: var(--ls-primary-text-color);
@@ -158,15 +153,16 @@ html[data-theme='light'] {
   --ls-page-blockquote-border-color: #799bbc;
   --ls-page-mark-color: #262626;
   --ls-page-mark-bg-color: #fef3ac;
-  --ls-page-inline-code-bg-color: #f7f7f7;
+  --ls-page-inline-code-bg-color: var(--ls-secondary-background-color);
   --ls-page-inline-code-color: var(--ls-primary-text-color);
   --ls-scrollbar-foreground-color: rgba(0, 0, 0, 0.1);
   --ls-scrollbar-background-color: rgba(0, 0, 0, 0.05);
   --ls-scrollbar-thumb-hover-color: rgba(0, 0, 0, 0.2);
   --ls-cloze-text-color: #0000cd;
   --ls-icon-color: #646464;
-  --ls-search-icon-color: var(--ls-icon-color);
-  --ls-a-chosen-bg: #f7f7f7;
+  --ls-search-icon-color: var(--ls-primary-text-color);
+  --ls-search-icon-hover-color: var(--ls-secondary-text-color);
+  --ls-a-chosen-bg: var(--ls-quaternary-background-color);
   --ls-pie-bg-color: #e1e1e1;
   --ls-pie-fg-color: #0a4a5d;
   --ls-highlight-color-gray: var(--color-gray-100);
@@ -189,6 +185,7 @@ html[data-theme='light'] {
   --color-level-3: var(--ls-quaternary-background-color);
   --color-level-4: #d0e6fa;
   --color-level-5: #bbdaf6;
+  --color-level-6: #a7cef1;
 }
 
 html:not(.is-native-android) {
@@ -525,7 +522,6 @@ i.ti {
 h1.title {
   margin-bottom: 1.5rem;
   color: var(--ls-title-text-color, #222);
-  font-family: -apple-system, system-ui, var(--ls-font-family), sans-serif;
   font-size: var(--ls-page-title-size, 36px);
   font-weight: 500;
 }
@@ -675,7 +671,7 @@ img.small {
 }
 
 a.tag {
-  font-size: 13px;
+  font-size: 0.9em;
   text-align: center;
   text-decoration: none;
   display: inline-block;
@@ -908,3 +904,13 @@ html[data-theme='dark'] .keyboard-shortcut > code {
 .katex .tag {
     overflow-x: clip;
 }
+
+html.is-mobile {
+  h1.title {
+    margin-bottom: 10px;
+  }
+
+  #journals .journal-item:first-child {
+    margin-top: 5px;
+  }
+}

+ 52 - 20
resources/css/tabler-extension.css

@@ -28,82 +28,114 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
-.tie-app-feature::before {
+.tie-add-link::before {
   content: "\ea01";
 }
 
-.tie-block::before {
+.tie-app-feature::before {
   content: "\ea02";
 }
 
-.tie-block-search::before {
+.tie-block::before {
   content: "\ea03";
 }
 
-.tie-connector::before {
+.tie-block-search::before {
   content: "\ea04";
 }
 
-.tie-h-auto::before {
+.tie-connector::before {
   content: "\ea05";
 }
 
-.tie-heading-off::before {
+.tie-h-auto::before {
   content: "\ea06";
 }
 
-.tie-new-block::before {
+.tie-heading-off::before {
   content: "\ea07";
 }
 
-.tie-new-page::before {
+.tie-internal-link::before {
   content: "\ea08";
 }
 
-.tie-new-whiteboard::before {
+.tie-link-to-block::before {
   content: "\ea09";
 }
 
-.tie-new-whiteboard-element::before {
+.tie-link-to-page::before {
   content: "\ea0a";
 }
 
-.tie-object-compact::before {
+.tie-link-to-whiteboard::before {
   content: "\ea0b";
 }
 
-.tie-object-expanded::before {
+.tie-move-to-sidebar-right::before {
   content: "\ea0c";
 }
 
-.tie-page::before {
+.tie-new-block::before {
   content: "\ea0d";
 }
 
-.tie-page-search::before {
+.tie-new-page::before {
   content: "\ea0e";
 }
 
-.tie-references-hide::before {
+.tie-new-whiteboard::before {
   content: "\ea0f";
 }
 
-.tie-references-show::before {
+.tie-new-whiteboard-element::before {
   content: "\ea10";
 }
 
-.tie-select-cursor::before {
+.tie-object-compact::before {
   content: "\ea11";
 }
 
-.tie-text::before {
+.tie-object-expanded::before {
   content: "\ea12";
 }
 
-.tie-whiteboard::before {
+.tie-open-as-page::before {
   content: "\ea13";
 }
 
-.tie-whiteboard-element::before {
+.tie-page::before {
   content: "\ea14";
 }
+
+.tie-page-search::before {
+  content: "\ea15";
+}
+
+.tie-references-hide::before {
+  content: "\ea16";
+}
+
+.tie-references-show::before {
+  content: "\ea17";
+}
+
+.tie-select-cursor::before {
+  content: "\ea18";
+}
+
+.tie-text::before {
+  content: "\ea19";
+}
+
+.tie-whiteboard::before {
+  content: "\ea1a";
+}
+
+.tie-whiteboard-element::before {
+  content: "\ea1b";
+}
+
+.tie-whiteboard-search::before {
+  content: "\ea1c";
+}

+ 0 - 1
resources/electron.html

@@ -54,7 +54,6 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/main.js"></script>
 <script defer src="./js/tabler.min.js"></script>
 <script defer src="./js/code-editor.js"></script>
-<script defer src="./js/age-encryption.js"></script>
 <script defer src="./js/excalidraw.js"></script>
 <script defer src="./js/tldraw.js"></script>
 </body>

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


BIN
resources/img/whiteboard-welcome-dark.png


BIN
resources/img/whiteboard-welcome-light.png


+ 0 - 1
resources/index.html

@@ -53,7 +53,6 @@ const portal = new MagicPortal(worker);
 <script defer src="./js/main.js"></script>
 <script defer src="./js/tabler.min.js"></script>
 <script defer src="./js/code-editor.js"></script>
-<script defer src="./js/age-encryption.js"></script>
 <script defer src="./js/excalidraw.js"></script>
 <script defer src="./js/tldraw.js"></script>
 </body>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
resources/js/lsplugin.core.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
resources/js/lsplugin.user.js


+ 9 - 0
resources/js/swiped-events.min.js

@@ -0,0 +1,9 @@
+/*!
+ * swiped-events.js - v1.1.6
+ * Pure JavaScript swipe events
+ * https://github.com/john-doherty/swiped-events
+ * @inspiration https://stackoverflow.com/questions/16348031/disable-scrolling-when-touch-moving-certain-element
+ * @author John Doherty <www.johndoherty.info>
+ * @license MIT
+ */
+!function(t,e){"use strict";"function"!=typeof t.CustomEvent&&(t.CustomEvent=function(t,n){n=n||{bubbles:!1,cancelable:!1,detail:void 0};var a=e.createEvent("CustomEvent");return a.initCustomEvent(t,n.bubbles,n.cancelable,n.detail),a},t.CustomEvent.prototype=t.Event.prototype),e.addEventListener("touchstart",function(t){if("true"===t.target.getAttribute("data-swipe-ignore"))return;s=t.target,r=Date.now(),n=t.touches[0].clientX,a=t.touches[0].clientY,u=0,i=0},!1),e.addEventListener("touchmove",function(t){if(!n||!a)return;var e=t.touches[0].clientX,r=t.touches[0].clientY;u=n-e,i=a-r},!1),e.addEventListener("touchend",function(t){if(s!==t.target)return;var e=parseInt(l(s,"data-swipe-threshold","20"),10),o=parseInt(l(s,"data-swipe-timeout","500"),10),c=Date.now()-r,d="",p=t.changedTouches||t.touches||[];Math.abs(u)>Math.abs(i)?Math.abs(u)>e&&c<o&&(d=u>0?"swiped-left":"swiped-right"):Math.abs(i)>e&&c<o&&(d=i>0?"swiped-up":"swiped-down");if(""!==d){var b={dir:d.replace(/swiped-/,""),touchType:(p[0]||{}).touchType||"direct",xStart:parseInt(n,10),xEnd:parseInt((p[0]||{}).clientX||-1,10),yStart:parseInt(a,10),yEnd:parseInt((p[0]||{}).clientY||-1,10)};s.dispatchEvent(new CustomEvent("swiped",{bubbles:!0,cancelable:!0,detail:b})),s.dispatchEvent(new CustomEvent(d,{bubbles:!0,cancelable:!0,detail:b}))}n=null,a=null,r=null},!1);var n=null,a=null,u=null,i=null,r=null,s=null;function l(t,n,a){for(;t&&t!==e.documentElement;){var u=t.getAttribute(n);if(u)return u;t=t.parentNode}return a}}(window,document);

+ 5 - 6
resources/package.json

@@ -1,7 +1,7 @@
 {
   "name": "Logseq",
   "productName": "Logseq",
-  "version": "0.8.11",
+  "version": "0.8.13",
   "main": "electron.js",
   "author": "Logseq",
   "license": "AGPL-3.0",
@@ -13,8 +13,7 @@
     "electron:make": "electron-forge make",
     "electron:make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
     "electron:publish:github": "electron-forge publish",
-    "rebuild:better-sqlite3": "electron-rebuild -v 19.0.12 -f -w better-sqlite3",
-    "rebuild:all": "electron-rebuild -v 19.0.12 -f",
+    "rebuild:all": "electron-rebuild -v 19.1.8 -f",
     "postinstall": "install-app-deps"
   },
   "config": {
@@ -38,7 +37,7 @@
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
     "posthog-js": "1.10.2",
-    "@logseq/rsapi": "0.0.50",
+    "@logseq/rsapi": "0.0.54",
     "electron-deeplink": "1.0.10",
     "abort-controller": "3.0.0"
   },
@@ -49,13 +48,13 @@
     "@electron-forge/maker-rpm": "^6.0.0-beta.57",
     "@electron-forge/maker-squirrel": "^6.0.0-beta.57",
     "@electron-forge/maker-zip": "^6.0.0-beta.57",
-    "electron": "19.0.12",
+    "electron": "19.1.8",
     "electron-builder": "^22.11.7",
     "electron-forge-maker-appimage": "https://github.com/logseq/electron-forge-maker-appimage.git",
     "electron-rebuild": "3.2.5"
   },
   "resolutions": {
-    "**/electron": "19.0.12",
+    "**/electron": "19.1.8",
     "**/node-gyp": "9.0.0"
   }
 }

+ 29 - 0
scripts/build-ios.sh

@@ -0,0 +1,29 @@
+#!/bin/bash
+
+set -ex
+
+unset LOGSEQ_APP_SERVER_URL
+export ENABLE_FILE_SYNC_PRODUCTION=true
+
+# yarn clean
+PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn install --force
+
+rm -rv public/static || true
+rm -rv ios/App/App/public || true
+
+yarn release-app
+
+rsync -avz --exclude node_modules --exclude '*.js.map' --exclude android ./static/ ./public/static/
+
+npx cap sync ios
+
+npx cap open ios
+
+echo "step 1(Xcode). Product > Archive (device should be Any iOS Device)"
+
+echo "step 2(Archive). Distribute App"
+
+echo "  - App Store Connect"
+echo "  - Upload"
+echo "  - (Default config, all checked)"
+echo "  - Upload"

+ 1 - 1
scripts/get-pkg-version.js

@@ -21,7 +21,7 @@ if (match) {
 if (process.argv[2] === 'nightly' || process.argv[2] === '') {
   const today = new Date()
   console.log(
-    ver + '+nightly.' + today.toISOString().split('T')[0].replaceAll('-', '')
+    ver + '-nightly.' + today.toISOString().split('T')[0].replaceAll('-', '')
   )
 } else {
   console.log(ver)

+ 47 - 0
scripts/patch-xcode-project.sh

@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# This script patches the iOS project to use the correct codesigning and provisioning profiles.
+
+set -e
+set -o pipefail
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+
+cd ${SCRIPT_DIR}/../ios/App
+
+ls -lah App.xcodeproj/project.pbxproj
+
+FILE="App.xcodeproj/project.pbxproj"
+
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC2FC1FED79650016851F:attributes:TargetAttributes:504EC3031FED79650016851F:ProvisioningStyle Manual' $FILE
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC2FC1FED79650016851F:attributes:TargetAttributes:5FFF7D6927E343FA00B00DA8:ProvisioningStyle Manual' $FILE
+
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC3171FED79650016851F:buildSettings:CODE_SIGN_STYLE Manual' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3171FED79650016851F:buildSettings:"CODE_SIGN_IDENTITY[sdk=iphoneos*]" String "iPhone Distribution"' $FILE
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC3171FED79650016851F:buildSettings:DEVELOPMENT_TEAM ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3171FED79650016851F:buildSettings:"DEVELOPMENT_TEAM[sdk=iphoneos*]" String K378MFWK59' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3171FED79650016851F:buildSettings:PROVISIONING_PROFILE_SPECIFIER String ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3171FED79650016851F:buildSettings:"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" String "match AppStore com.logseq.logseq"' $FILE
+
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC3181FED79650016851F:buildSettings:CODE_SIGN_STYLE Manual' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3181FED79650016851F:buildSettings:"CODE_SIGN_IDENTITY[sdk=iphoneos*]" String "iPhone Distribution"' $FILE
+/usr/libexec/PlistBuddy -c 'Set :objects:504EC3181FED79650016851F:buildSettings:DEVELOPMENT_TEAM ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3181FED79650016851F:buildSettings:"DEVELOPMENT_TEAM[sdk=iphoneos*]" String K378MFWK59' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3181FED79650016851F:buildSettings:PROVISIONING_PROFILE_SPECIFIER String ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:504EC3181FED79650016851F:buildSettings:"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" String "match AppStore com.logseq.logseq"' $FILE
+
+/usr/libexec/PlistBuddy -c 'Set :objects:5FFF7D7627E343FA00B00DA8:buildSettings:CODE_SIGN_STYLE Manual' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7627E343FA00B00DA8:buildSettings:"CODE_SIGN_IDENTITY[sdk=iphoneos*]" String "iPhone Distribution"' $FILE
+/usr/libexec/PlistBuddy -c 'Set :objects:5FFF7D7627E343FA00B00DA8:buildSettings:DEVELOPMENT_TEAM ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7627E343FA00B00DA8:buildSettings:"DEVELOPMENT_TEAM[sdk=iphoneos*]" String K378MFWK59' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7627E343FA00B00DA8:buildSettings:PROVISIONING_PROFILE_SPECIFIER String ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7627E343FA00B00DA8:buildSettings:"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" String "match AppStore com.logseq.logseq.ShareViewController"' $FILE
+
+/usr/libexec/PlistBuddy -c 'Set :objects:5FFF7D7727E343FA00B00DA8:buildSettings:CODE_SIGN_STYLE Manual' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7727E343FA00B00DA8:buildSettings:"CODE_SIGN_IDENTITY[sdk=iphoneos*]" String "iPhone Distribution"' $FILE
+/usr/libexec/PlistBuddy -c 'Set :objects:5FFF7D7727E343FA00B00DA8:buildSettings:DEVELOPMENT_TEAM ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7727E343FA00B00DA8:buildSettings:"DEVELOPMENT_TEAM[sdk=iphoneos*]" String K378MFWK59' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7727E343FA00B00DA8:buildSettings:PROVISIONING_PROFILE_SPECIFIER String ""' $FILE
+/usr/libexec/PlistBuddy -c 'Add :objects:5FFF7D7727E343FA00B00DA8:buildSettings:"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" String "match AppStore com.logseq.logseq.ShareViewController"' $FILE
+
+echo Patch OK!

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

@@ -3,6 +3,7 @@
   (:require [malli.core :as m]
             [malli.error :as me]
             [frontend.schema.handler.plugin-config :as plugin-config-schema]
+            [frontend.schema.handler.global-config :as global-config-schema]
             [clojure.pprint :as pprint]
             [clojure.edn :as edn]))
 
@@ -18,3 +19,17 @@
       (println "Found errors:")
       (pprint/pprint errors))
     (println "Valid!")))
+
+;; This fn should be split if the global and repo definitions diverge
+(defn validate-config-edn
+  "Validate a global or repo config.edn file"
+  [file]
+  (if-let [errors (->> file
+                       slurp
+                       edn/read-string
+                       (m/explain global-config-schema/Config-edn)
+                       me/humanize)]
+    (do
+      (println "Found errors:")
+      (pprint/pprint errors))
+    (println "Valid!")))

+ 6 - 11
shadow-cljs.edn

@@ -16,9 +16,6 @@
                         :code-editor
                         {:entries    [frontend.extensions.code]
                          :depends-on #{:main}}
-                        :age-encryption
-                        {:entries    [frontend.extensions.age-encryption]
-                         :depends-on #{:main}}
                         :excalidraw
                         {:entries    [frontend.extensions.excalidraw]
                          :depends-on #{:main}}
@@ -36,9 +33,12 @@
                                                 "externs.js"]
                            :warnings           {:fn-deprecated false
                                                 :redef false}}
+        :build-hooks      [(shadow.hooks/git-revision-hook "--long --always --dirty")]
         :closure-defines  {goog.debug.LOGGING_ENABLED       true
-                           frontend.config/ENABLE-PLUGINS   #shadow/env ["ENABLE_PLUGINS"   :as :bool :default true]
-                           frontend.config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true]}
+                           frontend.config/ENABLE-PLUGINS #shadow/env ["ENABLE_PLUGINS"   :as :bool :default true]
+                           frontend.config/ENABLE-FILE-SYNC-PRODUCTION #shadow/env ["ENABLE_FILE_SYNC_PRODUCTION" :as :bool :default true]
+                           frontend.config/TEST #shadow/env ["LOGSEQ_CI" :as :bool :default false]
+                           frontend.config/REVISION #shadow/env ["LOGSEQ_REVISION" :default "dev"]} ;; set by git-revision-hook
 
         ;; NOTE: electron, browser/mobile-app use different asset-paths.
         ;;   For browser/mobile-app devs, assets are located in /static/js(via HTTP root).
@@ -70,9 +70,7 @@
          :closure-defines {frontend.util/NODETEST true}
          :devtools        {:enabled false}
          ;; disable :static-fns to allow for with-redefs and repl development
-         :compiler-options {:static-fns false
-         ;; For custom conditional reading, see https://shadow-cljs.github.io/docs/UsersGuide.html#_conditional_reading
-                            :reader-features #{:node-test}}
+         :compiler-options {:static-fns false}
          :main            frontend.test.frontend-node-test-runner/main}
 
   :publishing {:target        :browser
@@ -83,9 +81,6 @@
                                :code-editor
                                {:entries    [frontend.extensions.code]
                                 :depends-on #{:main}}
-                               :age-encryption
-                               {:entries    [frontend.extensions.age-encryption]
-                                :depends-on #{:main}}
                                :excalidraw
                                {:entries    [frontend.extensions.excalidraw]
                                 :depends-on #{:main}}

+ 19 - 6
src/dev-cljs/shadow/hooks.clj

@@ -1,15 +1,15 @@
 (ns shadow.hooks
   (:require [clojure.java.shell :refer [sh]]
-            [clojure.string :as str]))
+            [clojure.string :as string]))
 
 ;; copied from https://gist.github.com/mhuebert/ba885b5e4f07923e21d1dc4642e2f182
 (defn exec [& cmd]
-  (let [cmd (str/split (str/join " " (flatten cmd)) #"\s+")
-        _ (println (str/join " " cmd))
+  (let [cmd (string/split (string/join " " (flatten cmd)) #"\s+")
+        _ (println (string/join " " cmd))
         {:keys [exit out err]} (apply sh cmd)]
     (if (zero? exit)
-      (when-not (str/blank? out)
-        (println out))
+      (when-not (string/blank? out)
+        (string/trim out))
       (println err))))
 
 (defn purge-css
@@ -27,5 +27,18 @@
     :dev
     (do
       (exec "mkdir -p" public-dir)
-      (exec "cp" css-source (str public-dir "/" (last (str/split css-source #"/"))))))
+      (exec "cp" css-source (str public-dir "/" (last (string/split css-source #"/"))))))
   state)
+
+(defn git-revision-hook
+  {:shadow.build/stage :configure}
+  [build-state & args]
+  (let [defines-in-config (get-in build-state [:shadow.build/config :closure-defines])
+        defines-in-options (get-in build-state [:compiler-options :closure-defines])
+        revision (exec "git" "describe" args)]
+    (prn ::git-revision-hook revision)
+    (-> build-state
+        (assoc-in [:shadow.build/config :closure-defines]
+                  (assoc defines-in-config 'frontend.config/REVISION revision))
+        (assoc-in [:compiler-options :closure-defines]
+                  (assoc defines-in-options 'frontend.config/REVISION revision)))))

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

@@ -137,7 +137,7 @@
                 ;; TODO: ugly, replace with ls-files and filter with ".map"
                 _ (p/all (map (fn [file]
                                 (. fs removeSync (path/join static-dir "js" (str file ".map"))))
-                              ["main.js" "code-editor.js" "excalidraw.js" "age-encryption.js"]))]
+                              ["main.js" "code-editor.js" "excalidraw.js"]))]
 
           (send-to-renderer
            :notification

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

@@ -62,7 +62,7 @@
   (let [watcher-opts (clj->js
                       {:ignored (fn [path]
                                   (utils/ignored-path? dir path))
-                       :ignoreInitial false
+                       :ignoreInitial true
                        :ignorePermissionErrors true
                        :interval polling-interval
                        :binaryInterval polling-interval

+ 23 - 4
src/electron/electron/handler.cljs

@@ -285,28 +285,47 @@
   (async/put! state/persistent-dbs-chan true)
   true)
 
+;; Search related IPCs
 (defmethod handle :search-blocks [_window [_ repo q opts]]
   (search/search-blocks repo q opts))
 
-(defmethod handle :rebuild-blocks-indice [_window [_ repo data]]
+(defmethod handle :search-pages [_window [_ repo q opts]]
+  (search/search-pages repo q opts))
+
+(defmethod handle :rebuild-indice [_window [_ repo block-data page-data]]
   (search/truncate-blocks-table! repo)
   ;; unneeded serialization
-  (search/upsert-blocks! repo (bean/->js data))
+  (search/upsert-blocks! repo (bean/->js block-data))
+  (search/truncate-pages-table! repo)
+  (search/upsert-pages! repo (bean/->js page-data))
   [])
 
 (defmethod handle :transact-blocks [_window [_ repo data]]
   (let [{:keys [blocks-to-remove-set blocks-to-add]} data]
+    ;; Order matters! Same id will delete then upsert sometimes.
     (when (seq blocks-to-remove-set)
       (search/delete-blocks! repo blocks-to-remove-set))
     (when (seq blocks-to-add)
       ;; unneeded serialization
       (search/upsert-blocks! repo (bean/->js blocks-to-add)))))
 
-(defmethod handle :truncate-blocks [_window [_ repo]]
-  (search/truncate-blocks-table! repo))
+(defmethod handle :transact-pages [_window [_ repo data]]
+  (let [{:keys [pages-to-remove-set pages-to-add]} data]
+    ;; Order matters! Same id will delete then upsert sometimes.
+    (when (seq pages-to-remove-set)
+      (search/delete-pages! repo pages-to-remove-set))
+    (when (seq pages-to-add)
+      ;; unneeded serialization
+      (search/upsert-pages! repo (bean/->js pages-to-add)))))
+
+(defmethod handle :truncate-indice [_window [_ repo]]
+  (search/truncate-blocks-table! repo)
+  (search/truncate-pages-table! repo))
 
 (defmethod handle :remove-db [_window [_ repo]]
   (search/delete-db! repo))
+;; ^^^^
+;; Search related IPCs End
 
 (defn clear-cache!
   [window]

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

@@ -39,9 +39,9 @@
            api #(str "https://api.github.com/repos/" repo "/" %)
            endpoint (api url-suffix)
            ^js res (fetch endpoint)
+           _ (debug "[Release URL] " endpoint "[Response Status/Text]" (.-status res) "-")
            res (response-transform res)
            res (.json res)
-           _ (debug "[Release URL] " endpoint)
            res (bean/->clj res)
            version (:tag_name res)
            asset (first (filter #(string/ends-with? (:name %) ".zip") (:assets res)))]

+ 211 - 46
src/electron/electron/search.cljs

@@ -1,4 +1,5 @@
 (ns electron.search
+  "Provides both page level and block level index"
   (:require ["path" :as path]
             ["fs-extra" :as fs]
             ["better-sqlite3" :as sqlite3]
@@ -31,25 +32,52 @@
   (when db
     (.prepare db sql)))
 
-(defn add-triggers!
+(defn add-blocks-fts-triggers!
+  "Table bindings of blocks tables and the blocks FTS virtual tables"
   [db]
-  (let [triggers ["CREATE TRIGGER IF NOT EXISTS blocks_ad AFTER DELETE ON blocks
-    BEGIN
-        DELETE from blocks_fts where rowid = old.id;
-    END;"
+  (let [triggers [;; add
+                  "CREATE TRIGGER IF NOT EXISTS blocks_ad AFTER DELETE ON blocks
+                  BEGIN
+                      DELETE from blocks_fts where rowid = old.id;
+                  END;"
+                  ;; insert
                   "CREATE TRIGGER IF NOT EXISTS blocks_ai AFTER INSERT ON blocks
-    BEGIN
-        INSERT INTO blocks_fts (rowid, uuid, content, page)
-        VALUES (new.id, new.uuid, new.content, new.page);
-    END;
-"
+                  BEGIN
+                      INSERT INTO blocks_fts (rowid, uuid, content, page)
+                      VALUES (new.id, new.uuid, new.content, new.page);
+                  END;"
+                  ;; update
                   "CREATE TRIGGER IF NOT EXISTS blocks_au AFTER UPDATE ON blocks
-    BEGIN
-        DELETE from blocks_fts where rowid = old.id;
-        INSERT INTO blocks_fts (rowid, uuid, content, page)
-        VALUES (new.id, new.uuid, new.content, new.page);
-    END;"
-                  ]]
+                  BEGIN
+                      DELETE from blocks_fts where rowid = old.id;
+                      INSERT INTO blocks_fts (rowid, uuid, content, page)
+                      VALUES (new.id, new.uuid, new.content, new.page);
+                  END;"]]
+    (doseq [trigger triggers]
+      (let [stmt (prepare db trigger)]
+        (.run ^object stmt)))))
+
+(defn add-pages-fts-triggers!
+  "Table bindings of pages tables and the pages FTS virtual tables"
+  [db]
+  (let [triggers [;; add
+                  "CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages
+                  BEGIN
+                      DELETE from pages_fts where rowid = old.id;
+                  END;"
+                  ;; insert
+                  "CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages
+                  BEGIN
+                      INSERT INTO pages_fts (rowid, uuid, content)
+                      VALUES (new.id, new.uuid, new.content);
+                  END;"
+                  ;; update
+                  "CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages
+                  BEGIN
+                      DELETE from pages_fts where rowid = old.id;
+                      INSERT INTO pages_fts (rowid, uuid, content)
+                      VALUES (new.id, new.uuid, new.content);
+                  END;"]]
     (doseq [trigger triggers]
       (let [stmt (prepare db trigger)]
         (.run ^object stmt)))))
@@ -68,6 +96,19 @@
   (let [stmt (prepare db "CREATE VIRTUAL TABLE IF NOT EXISTS blocks_fts USING fts5(uuid, content, page)")]
     (.run ^object stmt)))
 
+(defn create-pages-table!
+  [db]
+  (let [stmt (prepare db "CREATE TABLE IF NOT EXISTS pages (
+                        id INTEGER PRIMARY KEY,
+                        uuid TEXT NOT NULL,
+                        content TEXT NOT NULL)")]
+    (.run ^object stmt)))
+
+(defn create-pages-fts-table!
+  [db]
+  (let [stmt (prepare db "CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(uuid, content)")]
+    (.run ^object stmt)))
+
 (defn get-search-dir
   []
   (let [path (.getPath ^object app "userData")]
@@ -96,7 +137,10 @@
       (try (let [db (sqlite3 db-full-path nil)]
              (create-blocks-table! db)
              (create-blocks-fts-table! db)
-             (add-triggers! 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))
@@ -111,6 +155,36 @@
       (doseq [db-name dbs]
         (open-db! db-name)))))
 
+(defn- clj-list->sql
+  "Turn clojure list into SQL list
+   '(1 2 3 4)
+   ->
+   \"('1','2','3','4')\""
+  [ids]
+  (str "(" (->> (map (fn [id] (str "'" id "'")) ids)
+                (string/join ", ")) ")"))
+
+(defn upsert-pages!
+  [repo pages]
+  (if-let [db (get-db repo)]
+    ;; TODO: what if a CONFLICT on uuid
+    (let [insert (prepare db "INSERT INTO pages (id, uuid, content) VALUES (@id, @uuid, @content) ON CONFLICT (id) DO UPDATE SET content = @content")
+          insert-many (.transaction ^object db
+                                    (fn [pages]
+                                      (doseq [page pages]
+                                        (.run ^object insert page))))]
+      (insert-many pages))
+    (do
+      (open-db! repo)
+      (upsert-pages! repo pages))))
+
+(defn delete-pages!
+  [repo ids]
+  (when-let [db (get-db repo)]
+    (let [sql (str "DELETE from pages WHERE id IN " (clj-list->sql ids))
+          stmt (prepare db sql)]
+      (.run ^object stmt))))
+
 (defn upsert-blocks!
   [repo blocks]
   (if-let [db (get-db repo)]
@@ -128,9 +202,7 @@
 (defn delete-blocks!
   [repo ids]
   (when-let [db (get-db repo)]
-    (let [ids (->> (map (fn [id] (str "'" id "'")) ids)
-                   (string/join ", "))
-          sql (str "DELETE from blocks WHERE id IN (" ids ")")
+    (let [sql (str "DELETE from blocks WHERE id IN " (clj-list->sql ids))
           stmt (prepare db sql)]
       (.run ^object stmt))))
 
@@ -143,26 +215,45 @@
 
 (defn- search-blocks-aux
   [database sql input page limit]
-  (let [stmt (prepare database sql)]
-    (js->clj
-     (if page
-       (.all ^object stmt (int page) input limit)
-       (.all ^object stmt  input limit))
-     :keywordize-keys true)))
+  (try
+    (let [stmt (prepare database sql)]
+      (js->clj
+       (if page
+         (.all ^object stmt (int page) input limit)
+         (.all ^object stmt  input limit))
+       :keywordize-keys true))
+    (catch :default e
+      (logger/error "Search blocks failed: " (str e)))))
+
+(defn- get-match-inputs
+  [q]
+  (let [match-input (-> q
+                        (string/replace " and " " AND ")
+                        (string/replace " & " " AND ")
+                        (string/replace " or " " OR ")
+                        (string/replace " | " " OR ")
+                        (string/replace " not " " NOT "))]
+    (if (not= q match-input)
+      [(string/replace match-input "," "")]
+      [q
+       (str "\"" match-input "\"")])))
+
+(defn distinct-by
+  [f col]
+  (reduce
+   (fn [acc x]
+     (if (some #(= (f x) (f %)) acc)
+       acc
+       (vec (conj acc x))))
+   []
+   col))
 
 (defn search-blocks
+  ":page - the page to specificly search on"
   [repo q {:keys [limit page]}]
   (when-let [database (get-db repo)]
     (when-not (string/blank? q)
-      (let [match-input (-> q
-                            (string/replace " and " " AND ")
-                            (string/replace " & " " AND ")
-                            (string/replace " or " " OR ")
-                            (string/replace " | " " OR ")
-                            (string/replace " not " " NOT "))
-            match-input (if (not= q match-input)
-                          (string/replace match-input "," "")
-                          (str "\"" match-input "\""))
+      (let [match-inputs (get-match-inputs q)
             non-match-input (str "%" (string/replace q #"\s+" "%") "%")
             limit  (or limit 20)
             select "select rowid, uuid, content, page from blocks_fts where "
@@ -172,12 +263,82 @@
                            " content match ? order by rank limit ?")
             non-match-sql (str select
                                pg-sql
-                               " content like ? limit ?")]
+                               " content like ? limit ?")
+            matched-result (->>
+                            (map
+                              (fn [match-input]
+                                (search-blocks-aux database match-sql match-input page limit))
+                              match-inputs)
+                            (apply concat))]
+        (->>
+         (concat matched-result
+                 (search-blocks-aux database non-match-sql non-match-input page limit))
+         (distinct-by :rowid)
+         (take limit)
+         (vec))))))
+
+(defn- snippet-by
+  [content length]
+  (str (subs content 0 length) (when (> (count content) 250) "...")))
+
+(defn- search-pages-res-unpack
+  [arr]
+  (let [[rowid uuid content snippet] arr]
+    {:id      rowid
+     :uuid    uuid
+     :content content
+     ;; post processing
+     :snippet (let [;; Remove title from snippet
+                    flag-title " $<pfts_f6ld$ "
+                    flag-title-pos (string/index-of snippet flag-title)
+                    snippet (if flag-title-pos
+                              (subs snippet (+ flag-title-pos (count flag-title)))
+                              snippet)
+                    ;; Cut snippet to 250 chars for non-matched results
+                    flag-highlight "$pfts_2lqh>$ "
+                    snippet (if (string/includes? snippet flag-highlight)
+                              snippet
+                              (snippet-by snippet 250))]
+                snippet)}))
+
+(defn- search-pages-aux
+  [database sql input limit]
+  (let [stmt (prepare database sql)]
+    (try
+      (doall
+       (map search-pages-res-unpack (-> (.raw ^object stmt)
+                                        (.all input limit)
+                                        (js->clj))))
+      (catch :default e
+        (logger/error "Search page failed: " (str e))))))
+
+(defn search-pages
+  [repo q {:keys [limit]}]
+  (when-let [database (get-db repo)]
+    (when-not (string/blank? q)
+      (let [match-inputs (get-match-inputs q)
+            non-match-input (str "%" (string/replace q #"\s+" "%") "%")
+            limit  (or limit 20)
+            ;; https://www.sqlite.org/fts5.html#the_highlight_function
+            ;; the 2nd column in pages_fts (content)
+            ;; pfts_2lqh is a key for retrieval
+            ;; highlight and snippet only works for some matching with high rank
+            snippet-aux "snippet(pages_fts, 1, ' $pfts_2lqh>$ ', ' $<pfts_2lqh$ ', '...', 32)"
+            select (str "select rowid, uuid, content, " snippet-aux " from pages_fts where ")
+            match-sql (str select
+                           " content match ? order by rank limit ?")
+            non-match-sql (str select
+                               " content like ? limit ?")
+            matched-result (->>
+                            (map
+                              (fn [match-input]
+                                (search-pages-aux database match-sql match-input limit))
+                              match-inputs)
+                            (apply concat))]
         (->>
-         (concat
-          (search-blocks-aux database match-sql match-input page limit)
-          (search-blocks-aux database non-match-sql non-match-input page limit))
-         (distinct)
+         (concat matched-result
+                 (search-pages-aux database non-match-sql non-match-input limit))
+         (distinct-by :id)
          (take limit)
          (vec))))))
 
@@ -191,6 +352,16 @@
                         "delete from blocks_fts;")]
       (.run ^object stmt))))
 
+(defn truncate-pages-table!
+  [repo]
+  (when-let [database (get-db repo)]
+    (let [stmt (prepare database
+                        "delete from pages;")
+          _ (.run ^object stmt)
+          stmt (prepare database
+                        "delete from pages_fts;")]
+      (.run ^object stmt))))
+
 (defn delete-db!
   [repo]
   (when-let [database (get-db repo)]
@@ -205,9 +376,3 @@
   (when-let [database (get-db repo)]
     (let [stmt (prepare database sql)]
       (.all ^object stmt))))
-
-(comment
-  (def repo (first (keys @databases)))
-  (query repo
-         "select * from blocks_fts")
-  (delete-db! repo))

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

@@ -63,11 +63,21 @@
   [^js win ^js/URL parsed-url]
   (let [action (.-pathname parsed-url)]
     (cond
+      ;; url:     (string) Page url
+      ;; title:   (stirng) 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)
       (= action "/quickCapture")
-      (let [[url title content] (get-URL-decoded-params parsed-url ["url" "title" "content"])]
+      (let [[url title content page append] (get-URL-decoded-params parsed-url ["url" "title" "content" "page" "append"])]
         (send-to-focused-renderer "quickCapture" {:url url
                                                   :title title
-                                                  :content content} win))
+                                                  :content content
+                                                  :page page
+                                                  :append (if (nil? append)
+                                                            append
+                                                            (= append "true"))}
+                                  win))
 
       :else
       (send-to-focused-renderer "notification" {:type "error"

+ 64 - 26
src/main/electron/listener.cljs

@@ -1,27 +1,29 @@
 (ns electron.listener
   "System-component-like ns that defines listeners by event name to receive ipc
   messages from electron's main process"
-  (:require [frontend.state :as state]
+  (:require [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [datascript.core :as d]
+            [dommy.core :as dom]
+            [electron.ipc :as ipc]
+            [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.date :as date]
-            [frontend.handler.route :as route-handler]
-            [frontend.handler.editor :as editor-handler]
-            [frontend.handler.ui :as ui-handler]
-            [frontend.handler.file-sync :as file-sync-handler]
-            [frontend.config :as config]
-            [clojure.string :as string]
-            [cljs-bean.core :as bean]
-            [frontend.fs.watcher-handler :as watcher-handler]
-            [frontend.fs.sync :as sync]
             [frontend.db :as db]
             [frontend.db.model :as db-model]
-            [datascript.core :as d]
-            [electron.ipc :as ipc]
-            [frontend.ui :as ui]
+            [frontend.fs.sync :as sync]
+            [frontend.fs.watcher-handler :as watcher-handler]
+            [frontend.handler.editor :as editor-handler]
+            [frontend.handler.file-sync :as file-sync-handler]
             [frontend.handler.notification :as notification]
+            [frontend.handler.page :as page-handler]
             [frontend.handler.repo :as repo-handler]
+            [frontend.handler.route :as route-handler]
+            [frontend.handler.ui :as ui-handler]
             [frontend.handler.user :as user]
-            [dommy.core :as dom]))
+            [frontend.state :as state]
+            [frontend.ui :as ui]))
+
 
 (defn persist-dbs!
   []
@@ -33,6 +35,7 @@
                              :on-success #(ipc/ipc "persistent-dbs-saved")
                              :on-error   #(ipc/ipc "persistent-dbs-error")}))
 
+
 (defn listen-persistent-dbs!
   []
   ;; TODO: move "file-watcher" to electron.ipc.channels
@@ -41,6 +44,7 @@
    (fn [_req]
      (persist-dbs!))))
 
+
 (defn ^:large-vars/cleanup-todo listen-to-electron!
   []
   ;; TODO: move "file-watcher" to electron.ipc.channels
@@ -92,10 +96,13 @@
                        (let [{:keys [page-name block-id file]} (bean/->clj data)]
                          (cond
                            page-name
-                           (let [db-page-name (db-model/get-redirect-page-name page-name)]
+                           (let [db-page-name (db-model/get-redirect-page-name page-name)
+                                 whiteboard? (db-model/whiteboard-page? db-page-name)]
                              ;; No error handling required, as a page name is always valid
                              ;; Open new page if the page does not exist
-                             (editor-handler/insert-first-page-block-if-not-exists! db-page-name))
+                             (if whiteboard?
+                               (route-handler/redirect-to-whiteboard! page-name {:block-id block-id})
+                               (editor-handler/insert-first-page-block-if-not-exists! db-page-name)))
 
                            block-id
                            (if (db-model/get-block-by-uuid block-id)
@@ -149,25 +156,55 @@
 
   (js/window.apis.on "quickCapture"
                      (fn [args]
-                       (let [{:keys [url title content]} (bean/->clj args)
-                             page (or (state/get-current-page)
-                                      (string/lower-case (date/journal-name)))
+                       (let [{:keys [url title content page append]} (bean/->clj args)
+                             insert-today? (get-in (state/get-config)
+                                                   [:quick-capture-options :insert-today?]
+                                                   false)
+                             redirect-page? (get-in (state/get-config)
+                                                    [:quick-capture-options :redirect-page?]
+                                                    false)
+                             today-page (when (state/enable-journals?)
+                                          (string/lower-case (date/today)))
+                             page (if (or (= page "TODAY")
+                                          (and (string/blank? page) insert-today?))
+                                    today-page
+                                    (or (not-empty page)
+                                        (state/get-current-page)
+                                        today-page))
+                             page (or page "quick capture") ;; default to quick capture page, if journals are not enabled
                              format (db/get-page-format page)
                              time (date/get-current-time)
                              text (or (and content (not-empty (string/trim content))) "")
-                             link (if (not-empty title) (config/link-format format title url) url)
+                             link (if (string/includes? url "www.youtube.com/watch")
+                                    (str title " {{video " url "}}")
+                                    (if (not-empty title)
+                                      (config/link-format format title url)
+                                      url))
                              template (get-in (state/get-config)
                                               [:quick-capture-templates :text]
                                               "**{time}** [[quick capture]]: {text} {url}")
                              content (-> template
                                          (string/replace "{time}" time)
                                          (string/replace "{url}" link)
-                                         (string/replace "{text}" text))]
-                         (if (and (state/get-edit-block) (state/editing?))
-                           (editor-handler/insert content)
-                           (editor-handler/api-insert-new-block! content {:page page
-                                                                          :edit-block? false
-                                                                          :replace-empty-target? true})))))
+                                         (string/replace "{text}" text))
+                             edit-content (state/get-edit-content)
+                             edit-content-blank? (string/blank? edit-content)
+                             edit-content-include-capture? (and (not-empty edit-content)
+                                                                (string/includes? edit-content "[[quick capture]]"))]
+                         (if (and (state/editing?) (not append) (not edit-content-include-capture?))
+                           (if edit-content-blank?
+                             (editor-handler/insert content)
+                             (editor-handler/insert (str "\n" content)))
+
+                           (do
+                             (editor-handler/escape-editing)
+                             (when (not= page (state/get-current-page))
+                               (page-handler/create! page {:redirect? redirect-page?}))
+                             ;; Or else this will clear the newly inserted content
+                             (js/setTimeout #(editor-handler/api-insert-new-block! content {:page page
+                                                                                            :edit-block? true
+                                                                                            :replace-empty-target? true})
+                                            100))))))
 
   (js/window.apis.on "openNewWindowOfGraph"
                      ;; Handle open new window in renderer, until the destination graph doesn't rely on setting local storage
@@ -175,6 +212,7 @@
                      (fn [repo]
                        (ui-handler/open-new-window! repo))))
 
+
 (defn listen!
   []
   (listen-to-electron!)

+ 9 - 8
src/main/frontend/commands.cljs

@@ -28,6 +28,7 @@
 ;; TODO: move to frontend.handler.editor.commands
 
 (defonce angle-bracket "<")
+(defonce hashtag "#")
 (defonce colon ":")
 (defonce *current-command (atom nil))
 
@@ -321,7 +322,7 @@
 
 (defn insert!
   [id value
-   {:keys [last-pattern postfix-fn backward-pos forward-pos end-pattern backward-truncate-number]
+   {:keys [last-pattern postfix-fn backward-pos end-pattern backward-truncate-number command]
     :as _option}]
   (when-let [input (gdom/getElement id)]
     (let [last-pattern (when-not backward-truncate-number
@@ -334,11 +335,17 @@
                            (+ current-pos i)))
                        current-pos)
           orig-prefix (subs edit-content 0 current-pos)
+          postfix (subs edit-content current-pos)
+          postfix (if postfix-fn (postfix-fn postfix) postfix)
           space? (let [space? (when (and last-pattern orig-prefix)
                                 (let [s (when-let [last-index (string/last-index-of orig-prefix last-pattern)]
                                           (gp-util/safe-subs orig-prefix 0 last-index))]
                                   (not
                                    (or
+                                    (and (= :page-ref command)
+                                         (util/cjk-string? value)
+                                         (or (util/cjk-string? (str (last orig-prefix)))
+                                             (util/cjk-string? (str (first postfix)))))
                                     (and s
                                          (string/ends-with? s "(")
                                          (or (string/starts-with? last-pattern block-ref/left-parens)
@@ -364,8 +371,6 @@
 
                    :else
                    (util/replace-last last-pattern orig-prefix value space?))
-          postfix (subs edit-content current-pos)
-          postfix (if postfix-fn (postfix-fn postfix) postfix)
           new-value (cond
                       (string/blank? postfix)
                       prefix
@@ -379,11 +384,7 @@
                      (or backward-pos 0))]
       (when-not (string/blank? new-value)
         (state/set-block-content-and-last-pos! id new-value new-pos)
-        (cursor/move-cursor-to input
-                               (if (and (or backward-pos forward-pos)
-                                        (not= end-pattern page-ref/right-brackets))
-                                 new-pos
-                                 (inc new-pos)))))))
+        (cursor/move-cursor-to input new-pos)))))
 
 (defn simple-insert!
   [id value

+ 178 - 154
src/main/frontend/components/block.cljs

@@ -62,6 +62,7 @@
             [frontend.util.drawer :as drawer]
             [frontend.util.property :as property]
             [frontend.util.text :as text-util]
+            [frontend.handler.notification :as notification]
             [goog.dom :as gdom]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
@@ -265,14 +266,6 @@
     (when (seq images)
       (lightbox/preview-images! images))))
 
-(defn copy-image-to-clipboard
-  [src]
-  (-> (js/fetch src)
-      (.then (fn [data]
-               (-> (.blob data)
-                   (.then (fn [blob]
-                            (js/navigator.clipboard.write (clj->js [(js/ClipboardItem. (clj->js {(.-type blob) blob}))])))))))))
-
 (defonce *resizing-image? (atom false))
 (rum/defcs resizable-image <
   (rum/local nil ::size)
@@ -354,12 +347,13 @@
             (ui/icon "trash")]
 
            [:button.asset-action-btn
-            {:title (t :asset/copy)
-             :tabIndex "-1"
+            {:title         (t :asset/copy)
+             :tabIndex      "-1"
              :on-mouse-down util/stop
-             :on-click (fn [e]
-                         (util/stop e)
-                         (copy-image-to-clipboard image-src))}
+             :on-click      (fn [e]
+                              (util/stop e)
+                              (-> (util/copy-image-to-clipboard image-src)
+                                  (p/then #(notification/show! "Copied!" :success))))}
             (ui/icon "copy")]
 
            [:button.asset-action-btn
@@ -392,8 +386,12 @@
             share-fn (fn [event]
                        (util/stop event)
                        (when (mobile-util/native-platform?)
-                         (.share Share #js {:url path
-                                            :title "Open file with your favorite app"})))]
+                         ;; File URL must be legal, so filename muse be URI-encoded
+                         (let [[rel-dir basename] (util/get-dir-and-basename href)
+                               basename (js/encodeURIComponent basename)
+                               asset-url (str repo-dir rel-dir "/" basename)]
+                           (.share Share (clj->js {:url asset-url
+                                                   :title "Open file with your favorite app"})))))]
 
         (cond
           (contains? config/audio-formats ext)
@@ -408,7 +406,7 @@
           [:a.asset-ref.is-plaintext {:href (rfe/href :file {:path path})
                                       :on-click (fn [_event]
                                                   (p/let [result (fs/read-file repo-dir path)]
-                                                    (db/set-file-content! repo path result )))}
+                                                    (db/set-file-content! repo path result)))}
            title]
 
           (= ext :pdf)
@@ -567,13 +565,15 @@
    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)
-        config (assoc config :whiteboard-page? whiteboard-page?)]
+        config (assoc config :whiteboard-page? whiteboard-page?)
+        untitled? (model/untitled-page? page-name)]
     [:a
      {:tabIndex "0"
       :class (cond-> (if tag? "tag" "page-ref")
                (or (:property? config)
                    (= (:block/type page-entity) "property"))
-               (str " property-key"))
+               (str " property-key")
+               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?))
       :on-key-up (fn [e] (when (and e (= (.-key e) "Enter"))
@@ -597,9 +597,14 @@
 
          :else
          (let [original-name (util/get-page-original-name page-entity)
-               s (if (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)
-                   (pdf-assets/human-page-name original-name))
+               s (cond untitled?
+                       (t :untitled)
+
+                       (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))
+
+                       :else
+                       (pdf-assets/human-page-name original-name))
                _ (when-not page-entity (js/console.warn "page-inner's page-entity is nil, given page-name: " page-name
                                                         " page-name-in-block: " page-name-in-block))]
            (if tag? (str "#" s) s))))]))
@@ -619,10 +624,7 @@
           inner (page-inner config
                             page-name-in-block
                             page-name
-                            redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?)
-          inner (if whiteboard-page?
-                  [:<> [:span.text-gray-500 (ui/icon "whiteboard" {:extension? true}) " "] inner]
-                  inner)]
+                            redirect-page-name page-entity contents-page? children html-export? label whiteboard-page?)]
       (cond
         (:breadcrumb? config)
         (or (:block/original-name page)
@@ -984,7 +986,7 @@
         [:a.asset-ref.is-pdf
          {:on-mouse-down (fn [_event]
                            (when-let [current (pdf-assets/inflate-asset s)]
-                             (state/set-state! :pdf/current current)))}
+                             (state/set-current-pdf! current)))}
          (or label-text
              (->elem :span (map-inline config label)))]
 
@@ -1584,9 +1586,7 @@
 (defn- bullet-drag-start
   [event block uuid block-id]
   (editor-handler/highlight-block! uuid)
-  (.setData (gobj/get event "dataTransfer")
-            "block-uuid"
-            uuid)
+  (editor-handler/block->data-transfer! uuid event)
   (.setData (gobj/get event "dataTransfer")
             "block-dom-id"
             block-id)
@@ -1625,23 +1625,22 @@
     (when (and (coll? children)
                (seq children)
                (not collapsed?))
-      (let [doc-mode? (state/sub :document/mode?)]
-        [:div.block-children-container.flex {:style {:margin-left (if doc-mode? 18 29)}}
-         [:div.block-children-left-border
-          {:on-click (fn [_]
-                       (editor-handler/toggle-open-block-children! (:block/uuid block)))}]
-         [:div.block-children.w-full {:style    {:display     (if collapsed? "none" "")}}
-          (for [child children]
-            (when (map? child)
-              (let [child (dissoc child :block/meta)
-                    config (cond->
-                            (-> config
-                                (assoc :block/uuid (:block/uuid child))
-                                (dissoc :breadcrumb-show? :embed-parent))
-                             (or ref? query?)
-                             (assoc :ref-query-child? true))]
-                (rum/with-key (block-container config child)
-                  (:block/uuid child)))))]]))))
+      [:div.block-children-container.flex
+       [:div.block-children-left-border
+        {:on-click (fn [_]
+                     (editor-handler/toggle-open-block-children! (:block/uuid block)))}]
+       [:div.block-children.w-full {:style {:display (if collapsed? "none" "")}}
+        (for [child children]
+          (when (map? child)
+            (let [child  (dissoc child :block/meta)
+                  config (cond->
+                           (-> config
+                               (assoc :block/uuid (:block/uuid child))
+                               (dissoc :breadcrumb-show? :embed-parent))
+                           (or ref? query?)
+                           (assoc :ref-query-child? true))]
+              (rum/with-key (block-container config child)
+                            (:block/uuid child)))))]])))
 
 (defn- block-content-empty?
   [{:block/keys [properties title body]}]
@@ -1656,30 +1655,29 @@
    (every? #(= % ["Horizontal_Rule"]) body)))
 
 (rum/defcs block-control < rum/reactive
-  [state config block uuid block-id collapsed? *control-show? edit?]
+  [state config block uuid block-id collapsed? *control-show? edit? has-child?]
   (let [doc-mode? (state/sub :document/mode?)
         control-show? (util/react *control-show?)
         ref? (:ref? config)
-        empty-content? (block-content-empty? block)]
-    [:div.mr-1.flex.flex-row.items-center.sm:mr-2
-     {:style {:height 24
-              :margin-top 0
-              :float "left"}}
-
-     [:a.block-control
-      {:id (str "control-" uuid)
-       :on-click (fn [event]
-                   (util/stop event)
-                   (state/clear-edit!)
-                   (if ref?
-                     (state/toggle-collapsed-block! uuid)
-                     (if collapsed?
-                       (editor-handler/expand-block! uuid)
-                       (editor-handler/collapse-block! uuid))))}
-      [:span {:class (if (and control-show?
-                              (or collapsed?
-                                  (editor-handler/collapsable? uuid {:semantic? true}))) "control-show cursor-pointer" "control-hide")}
-       (ui/rotating-arrow collapsed?)]]
+        empty-content? (block-content-empty? block)
+        fold-button-right? (state/enable-fold-button-right?)]
+    [:div.block-control-wrap.mr-1.flex.flex-row.items-center.sm:mr-2
+     (when (or (not fold-button-right?) has-child?)
+       [:a.block-control
+        {:id       (str "control-" uuid)
+         :on-click (fn [event]
+                     (util/stop event)
+                     (state/clear-edit!)
+                     (if ref?
+                       (state/toggle-collapsed-block! uuid)
+                       (if collapsed?
+                         (editor-handler/expand-block! uuid)
+                         (editor-handler/collapse-block! uuid))))}
+        [:span {:class (if (and control-show?
+                                (or collapsed?
+                                    (editor-handler/collapsable? uuid {:semantic? true}))) "control-show cursor-pointer" "control-hide")}
+         (ui/rotating-arrow collapsed?)]])
+
      (let [bullet [:a {:on-click (fn [event]
                                    (bullet-on-click event block uuid))}
                    [:span.bullet-container.cursor
@@ -1853,7 +1851,8 @@
       (when bg-color
         {:style {:background-color (if (some #{bg-color} ui/block-background-colors)
                                      (str "var(--ls-highlight-color-" bg-color ")")
-                                     bg-color)}
+                                     bg-color)
+                 :color (when-not (some #{bg-color} ui/block-background-colors) "white")}
          :class "px-1 with-bg-color"}))
      (remove-nils
       (concat
@@ -2285,10 +2284,17 @@
       [:div.more (ui/icon "dots-circle-horizontal" {:size 18})])]])
 
 (rum/defcs block-content-or-editor < rum/reactive
-  (rum/local true ::hide-block-refs?)
+  {:init (fn [state]
+           (let [block (second (:rum/args state))
+                 config (first (:rum/args state))
+                 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)]
+             (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?)
-        hide-block-refs? @*hide-block-refs?
+        hide-block-refs? (rum/react *hide-block-refs?)
         editor-box (get config :editor-box)
         editor-id (str "editor-" edit-input-id)
         slide? (:slide? config)
@@ -2426,54 +2432,57 @@
 (rum/defc breadcrumb-separator [] [:span.mx-2.opacity-50 "➤"])
 
 (defn breadcrumb
+  "block-id - uuid of the target block of breadcrumb. page uuid is also acceptable"
   [config repo block-id {:keys [show-page? indent? end-separator? level-limit _navigating-block]
                          :or {show-page? true
                               level-limit 3}
                          :as opts}]
-  (let [parents (db/get-block-parents repo block-id (inc level-limit))
-        page (db/get-block-page repo block-id)
-        page-name (:block/name page)
-        page-original-name (:block/original-name page)
-        show? (or (seq parents) show-page? page-name)
-        parents (if (= page-name (:block/name (first parents)))
-                  (rest parents)
-                  parents)
-        more? (> (count parents) level-limit)
-        parents (if more? (take-last level-limit parents) parents)
-        config (assoc config :breadcrumb? true)]
-    (when show?
-      (let [page-name-props (when show-page?
-                              [page
-                               (page-cp (dissoc config :breadcrumb? true) page)
-                               {:block/name (or page-original-name page-name)}])
-            parents-props (doall
-                           (for [{:block/keys [uuid name content] :as block} parents]
-                             (when-not name ; not page
-                               (let [{:block/keys [title body]} (block/parse-title-and-body
-                                                                 uuid
-                                                                 (:block/format block)
-                                                                 (:block/pre-block? block)
-                                                                 content)
-                                     config (assoc config :block/uuid uuid)]
-                                 [block
-                                  (if (seq title)
-                                    (->elem :span (map-inline config title))
-                                    (->elem :div (markup-elements-cp config body)))]))))
-            breadcrumb (->> (into [] parents-props)
-                            (concat [page-name-props] (when more? [:more]))
-                            (filterv identity)
-                            (map (fn [x] (if (vector? x)
-                                           (let [[block label] x]
-                                             (breadcrumb-fragment config block label opts))
-                                           [:span.opacity-70 "⋯"])))
-                            (interpose (breadcrumb-separator)))]
-        [:div.breadcrumb.block-parents.flex-row.flex-1
-         {:class (when (seq breadcrumb)
-                   (str (when-not (:search? config)
-                          " my-2")
-                        (when indent?
-                          " ml-4")))}
-         breadcrumb (when end-separator? (breadcrumb-separator))]))))
+  (when block-id
+    (let [parents (db/get-block-parents repo block-id (inc level-limit))
+          page (or (db/get-block-page repo block-id) ;; only return for block uuid
+                   (model/query-block-by-uuid block-id)) ;; return page entity when received page uuid
+          page-name (:block/name page)
+          page-original-name (:block/original-name page)
+          show? (or (seq parents) show-page? page-name)
+          parents (if (= page-name (:block/name (first parents)))
+                    (rest parents)
+                    parents)
+          more? (> (count parents) level-limit)
+          parents (if more? (take-last level-limit parents) parents)
+          config (assoc config :breadcrumb? true)]
+      (when show?
+        (let [page-name-props (when show-page?
+                                [page
+                                 (page-cp (dissoc config :breadcrumb? true) page)
+                                 {:block/name (or page-original-name page-name)}])
+              parents-props (doall
+                             (for [{:block/keys [uuid name content] :as block} parents]
+                               (when-not name ; not page
+                                 (let [{:block/keys [title body]} (block/parse-title-and-body
+                                                                   uuid
+                                                                   (:block/format block)
+                                                                   (:block/pre-block? block)
+                                                                   content)
+                                       config (assoc config :block/uuid uuid)]
+                                   [block
+                                    (if (seq title)
+                                      (->elem :span (map-inline config title))
+                                      (->elem :div (markup-elements-cp config body)))]))))
+              breadcrumb (->> (into [] parents-props)
+                              (concat [page-name-props] (when more? [:more]))
+                              (filterv identity)
+                              (map (fn [x] (if (vector? x)
+                                             (let [[block label] x]
+                                               (rum/with-key (breadcrumb-fragment config block label opts) (:block/uuid block)))
+                                             [:span.opacity-70 "⋯"])))
+                              (interpose (breadcrumb-separator)))]
+          [:div.breadcrumb.block-parents.flex-row.flex-1
+           {:class (when (seq breadcrumb)
+                     (str (when-not (:search? config)
+                            " my-2")
+                          (when indent?
+                            " ml-4")))}
+           breadcrumb (when end-separator? (breadcrumb-separator))])))))
 
 (defn- block-drag-over
   [event uuid top? block-id *move-to]
@@ -2687,20 +2696,24 @@
                         (block-handler/on-touch-move event block uuid edit? *show-left-menu? *show-right-menu?))
        :on-touch-end (fn [event]
                        (block-handler/on-touch-end event block uuid *show-left-menu? *show-right-menu?))
-       :on-touch-cancel block-handler/on-touch-cancel
+       :on-touch-cancel (fn [_e]
+                          (block-handler/on-touch-cancel *show-left-menu? *show-right-menu?))
        :on-mouse-over (fn [e]
                         (block-mouse-over e *control-show? block-id doc-mode?))
        :on-mouse-leave (fn [e]
                          (block-mouse-leave e *control-show? block-id doc-mode?))}
       (when (not slide?)
-        (block-control config block uuid block-id collapsed? *control-show? edit?))
+        (block-control config block uuid block-id collapsed? *control-show? edit? has-child?))
 
       (when @*show-left-menu?
         (block-left-menu config block))
 
       (if whiteboard-block?
         (block-reference {} (str uuid) nil)
-        (block-content-or-editor config block edit-input-id block-id edit? false))
+        ;; Not embed self
+        (let [hide-block-refs-count? (and (:embed? config)
+                                          (= (:block/uuid block) (:embed-id config)))]
+          (block-content-or-editor config block edit-input-id block-id edit? hide-block-refs-count?)))
 
       (when @*show-right-menu?
         (block-right-menu config block edit?))]
@@ -3183,9 +3196,9 @@
              :else
              [:<>
               (lazy-editor/editor config (str (d/squuid)) attr code options)
-              (let [options (:options options)]
+              (let [options (:options options) block (:block config)]
                 (when (and (= language "clojure") (contains? (set options) ":results"))
-                  (sci/eval-result code)))])])))))
+                  (sci/eval-result code block)))])])))))
 
 (defn ^:large-vars/cleanup-todo markup-element-cp
   [{:keys [html-export?] :as config} item]
@@ -3387,22 +3400,29 @@
     (let [last-block-id (:db/id (last flat-blocks))]
       (block-handler/load-more! db-id last-block-id))))
 
+(defn- loading-more-data!
+  [config *loading? flat-blocks initial?]
+  ;; To prevent scrolling after inserting new blocks
+  (when (or initial?
+            (and (not initial?) (> (- (util/time-ms) (:start-time config)) 100)))
+    (reset! *loading? true)
+    (load-more-blocks! config flat-blocks)
+    (reset! *loading? false)))
+
 (rum/defcs lazy-blocks < rum/reactive
   (rum/local nil ::loading?)
   {:init (fn [state]
-           (assoc state ::id (str (random-uuid))))}
+           (assoc state ::id (str (random-uuid))))
+   :did-mount (fn [state]
+                (let [[config _ flat-blocks] (:rum/args state)]
+                  (loading-more-data! config (::loading? state) flat-blocks true))
+                state)}
   [state config blocks flat-blocks]
   (let [db-id (:db/id config)
         *loading? (::loading? state)]
     (if-not db-id
       (block-list config blocks)
-      (let [loading-more-data! (fn []
-                                 ;; To prevent scrolling after inserting new blocks
-                                 (when (> (- (util/time-ms) (:start-time config)) 100)
-                                   (reset! *loading? true)
-                                   (load-more-blocks! config flat-blocks)
-                                   (reset! *loading? false)))
-            has-more? (and
+      (let [has-more? (and
                        (>= (count flat-blocks) model/initial-blocks-length)
                        (some? (model/get-next-open-block (db/get-db) (last flat-blocks) db-id)))
             dom-id (str "lazy-blocks-" (::id state))]
@@ -3410,7 +3430,7 @@
          (ui/infinite-list
           "main-content-container"
           (block-list config blocks)
-          {:on-load loading-more-data!
+          {:on-load #(loading-more-data! config *loading? flat-blocks false)
            :bottom-reached (fn []
                              (when-let [node (gdom/getElement dom-id)]
                                (ui/bottom-reached? node 300)))
@@ -3455,7 +3475,7 @@
              (assoc state
                     ::initial-block    first-block
                     ::navigating-block (atom (:block/uuid first-block)))))}
-  [state block config]
+  [state blocks config]
   (let [repo (state/get-current-repo)
         *navigating-block (::navigating-block state)
         navigating-block (rum/react *navigating-block)
@@ -3468,10 +3488,10 @@
                  (let [block navigating-block-entity]
                    (db/get-paginated-blocks repo (:db/id block)
                                             {:scoped-block-id (:db/id block)}))
-                 [block])]
+                 blocks)]
     [:div
      (when (:breadcrumb-show? config)
-       (breadcrumb config (state/get-current-repo) (or navigating-block (:block/uuid block))
+       (breadcrumb config (state/get-current-repo) (or navigating-block (:block/uuid (first blocks)))
                    {:show-page? false
                     :navigating-block *navigating-block}))
      (blocks-container blocks (assoc config
@@ -3494,7 +3514,8 @@
            (fn []
              (let [alias? (:block/alias? page)
                    page (db/entity (:db/id page))
-                   blocks' (tree/non-consecutive-blocks->vec-tree blocks)]
+                   blocks (tree/non-consecutive-blocks->vec-tree blocks)
+                   parent-blocks (group-by :block/parent blocks)]
                [:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
                             (:ref? config)
                             (assoc :class "color-level px-2 sm:px-7 py-2 rounded"))
@@ -3502,34 +3523,37 @@
                  [:div
                   (page-cp config page)
                   (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
-                 (for [block blocks']
+                 (for [[parent blocks] parent-blocks]
                    (rum/with-key
-                     (breadcrumb-with-container block config)
-                     (:db/id block)))
+                     (breadcrumb-with-container blocks config)
+                     (:db/id parent)))
                  {:debug-id page
                   :trigger-once? false})])))))]
 
      (and (:ref? config) (:group-by-page? config))
      [:div.flex.flex-col
       (let [blocks (sort-by (comp :block/journal-day first) > blocks)]
-        (for [[page parent-blocks] blocks]
-         (ui/lazy-visible
-          (fn []
-            (let [alias? (:block/alias? page)
-                  page (db/entity (:db/id page))]
-              [:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
-                           (:ref? config)
-                           (assoc :class "color-level px-2 sm:px-7 py-2 rounded"))
-               (ui/foldable
-                [:div
-                 (page-cp config page)
-                 (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
-                (for [block parent-blocks]
-                  (let [block' (update block :block/children tree/non-consecutive-blocks->vec-tree)]
-                    (rum/with-key
-                      (breadcrumb-with-container block' config)
-                      (:db/id block'))))
-                {:debug-id page})])))))]
+        (for [[page page-blocks] blocks]
+          (ui/lazy-visible
+           (fn []
+             (let [alias? (:block/alias? page)
+                   page (db/entity (:db/id page))
+                   page-blocks' (tree/non-consecutive-blocks->vec-tree page-blocks)
+                   parent-blocks (group-by :block/parent page-blocks')]
+               [:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
+                            (:ref? config)
+                            (assoc :class "color-level px-2 sm:px-7 py-2 rounded"))
+                (ui/foldable
+                 [:div
+                  (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 2))) blocks)]
+                     (rum/with-key
+                      (breadcrumb-with-container blocks' config)
+                      (:db/id parent))))
+                 {:debug-id page})])))))]
 
      (and (:group-by-page? config)
           (vector? (first blocks)))
@@ -3540,7 +3564,7 @@
             (when (seq blocks)
               (let [alias? (:block/alias? page)
                     page (db/entity (:db/id page))
-                    whiteboard? (= "whiteboard" (:block/type page))]
+                    whiteboard? (model/whiteboard-page? page)]
                 [:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
                              (:ref? config)
                              (assoc :class "color-level px-2 sm:px-7 py-2 rounded"))

+ 80 - 71
src/main/frontend/components/block.css

@@ -116,7 +116,7 @@
 .block-body ul,
 .block-body ol,
 .block-body dl {
-  margin-bottom: 0em;
+  margin-bottom: 0;
 
   > li {
     margin: 0;
@@ -137,6 +137,7 @@
 
 .block-children-container {
   position: relative;
+  margin-left: 29px;
 }
 
 .block-children-left-border {
@@ -171,6 +172,11 @@
   }
 }
 
+.block-control-wrap {
+  height: 24px;
+  margin-top: 0;
+}
+
 .block-control, .block-control:hover {
   text-decoration: none;
   cursor: default;
@@ -310,9 +316,9 @@
   padding: 2px 4px;
   opacity: 0.7;
   font-size: 85%;
-  margin: 0 2px 0 0px;
+  margin: 0 2px 0 0;
   font-weight: 650;
-  border: 0px;
+  border: 0;
 }
 
 .marker-switch {
@@ -362,14 +368,14 @@
 
 .ls-block h3,
 .editor-inner .h3.uniline-block {
-  font-size: 1.17em;
-  min-height: 1.17em;
+  font-size: 1.2em;
+  min-height: 1.2em;
 }
 
 .ls-block h4,
 .editor-inner .h4.uniline-block {
-  font-size: 1.12em;
-  min-height: 1.12em;
+  font-size: 1em;
+  min-height: 1em;
 }
 
 .ls-block h5,
@@ -393,7 +399,8 @@
 .ls-block :is(h1, h2),
 .editor-inner .uniline-block:is(.h1, .h2) {
   border-bottom: 1px solid var(--ls-quaternary-background-color);
-  margin: 0.4em 0 0;
+  margin: 0.125em 0;
+  padding-bottom: 0.125em;
 }
 
 .block-ref .ls-block :is(h1, h2),
@@ -406,86 +413,80 @@
   font-size: 1rem;
 }
 
-.document-mode .ls-block h1,
-.document-mode .editor-inner .h1 {
-  margin: 0.67em 0;
-}
-
-.document-mode .ls-block h2,
-.document-mode .editor-inner .h2 {
-  margin: 0.75em 0;
-}
-
-.document-mode .ls-block h3,
-.document-mode .editor-inner .h3 {
-  margin: 0.83em 0;
-}
+.document-mode {
+  & .ls-block h1,
+  & .editor-inner .h1 {
+    margin: 0.67em 0;
+  }
 
-.document-mode .ls-block h4,
-.document-mode .editor-inner .h4 {
-  margin: 1.12em 0;
-}
+  & .ls-block h2,
+  & .editor-inner .h2 {
+    margin: 0.75em 0;
+  }
 
-.document-mode .ls-block h5,
-.document-mode .editor-inner .h5 {
-  margin: 1.5em 0;
-}
+  & .ls-block h3,
+  & .editor-inner .h3 {
+    margin: 0.83em 0;
+  }
 
-.document-mode .ls-block h6,
-.document-mode .editor-inner .h6 {
-  margin: 1.67em 0;
-}
+  & .ls-block h4,
+  & .editor-inner .h4 {
+    margin: 1.12em 0;
+  }
 
-.document-mode .block-children {
-  border-left: 0px solid;
-}
+  & .ls-block h5,
+  & .editor-inner .h5 {
+    margin: 1.5em 0;
+  }
 
-.document-mode .ls-block {
-  margin-bottom: 1rem;
+  & .ls-block h6,
+  & .editor-inner .h6 {
+    margin: 1.67em 0;
+  }
 }
 
 .color-level {
   background-color: var(--color-level-1);
-}
 
-.color-level .color-level {
-  background-color: var(--color-level-2);
-}
+  & .color-level {
+    background-color: var(--color-level-2);
 
-.color-level .color-level .color-level {
-  background-color: var(--color-level-3);
-}
+    & .color-level {
+      background-color: var(--color-level-3);
 
-.color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-4);
-}
+      & .color-level {
+        background-color: var(--color-level-4);
 
-.color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-5);
-}
+        & .color-level {
+          background-color: var(--color-level-5);
 
-.color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-3);
-}
+          & .color-level {
+            background-color: var(--color-level-6);
 
-.color-level .color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-4);
-}
+            & .color-level {
+              background-color: var(--color-level-4);
 
-.color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-5);
-}
+              & .color-level {
+                background-color: var(--color-level-5);
 
-.color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-3);
-}
+                & .color-level {
+                  background-color: var(--color-level-6);
 
-.color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-4);
-}
+                  & .color-level {
+                    background-color: var(--color-level-4);
 
-.color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level .color-level {
-  background-color: var(--color-level-5);
+                    & .color-level {
+                      background-color: var(--color-level-6);
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
 }
 
 .bullet-container {
@@ -521,16 +522,24 @@ a:hover > .bullet-container {
   background-color: var(--ls-block-bullet-border-color, #ced9e0);
 }
 
-.doc-mode {
+.content.doc-mode {
   margin-left: -16px;
 
   .block-children-left-border {
     display: none;
   }
 
+  .block-children {
+    border-left: none;
+  }
+
   .hide-inner-bullet .bullet {
     display: none;
   }
+
+  .block-children-container {
+    margin-left: 18px;
+  }
 }
 
 /* copied from https://github.com/drdogbot7/tailwindcss-responsive-embed */
@@ -648,4 +657,4 @@ html.is-mac {
       cursor: pointer;
     }
   }
-}
+}

+ 28 - 4
src/main/frontend/components/command_palette.css

@@ -18,11 +18,35 @@
       border: none;
       border-radius: unset !important;
       background: none;
-    }
 
-    .chosen {
-      background-color: var(--ls-quaternary-background-color);
-      color: var(--ls-secondary-text-color);
+      .type-icon {
+        color: var(--ls-search-icon-color);
+
+        &.highlight {
+          color: var(--ls-selection-text-color);
+          border-color: var(--ls-selection-background-color);
+
+          &:before {
+            opacity: 0.5;
+            background: var(--ls-selection-background-color);
+          }
+        }
+      }
+
+      &.chosen .type-icon,
+      &:hover .type-icon {
+        color: var(--ls-search-icon-hover-color);
+      }
+
+      &.chosen,
+      &.chosen p {
+        background-color: var(--ls-a-chosen-bg);
+        color: var(--ls-secondary-text-color);
+      }
+
+      &:hover p {
+        color: var(--ls-secondary-text-color);
+      }
     }
 
     .command-results-wrap,

+ 12 - 54
src/main/frontend/components/content.cljs

@@ -6,12 +6,9 @@
             [frontend.components.editor :as editor]
             [frontend.components.page-menu :as page-menu]
             [frontend.components.export :as export]
-            [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.extensions.srs :as srs]
-            [frontend.format :as format]
-            [frontend.format.protocol :as protocol]
             [frontend.handler.common :as common-handler]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.image :as image-handler]
@@ -31,29 +28,6 @@
 
 ;; TODO i18n support
 
-(defn- set-format-js-loading!
-  [format value]
-  (when format
-    (swap! state/state assoc-in [:format/loading format] value)))
-
-(defn- lazy-load
-  [format]
-  (let [format (gp-util/normalize-format format)]
-    (when-let [record (format/get-format-record format)]
-      (when-not (protocol/loaded? record)
-        (set-format-js-loading! format true)
-        (protocol/lazyLoad record
-                           (fn [_result]
-                             (set-format-js-loading! format false)))))))
-
-(defn lazy-load-js
-  [state]
-  (when-let [format (:format (last (:rum/args state)))]
-    (let [loader? (contains? config/html-render-formats format)]
-      (when loader?
-        (when-not (format/loaded? format)
-          (lazy-load format))))))
-
 (rum/defc custom-context-menu-content
   []
   [:.menu-links-wrapper
@@ -362,12 +336,9 @@
     (let [page-menu-options (page-menu/page-menu page)]
       [:.menu-links-wrapper
        (for [{:keys [title options]} page-menu-options]
-         (ui/menu-link
-          (merge
-           {:key title}
-           options)
-          title
-          nil))])))
+         (rum/with-key
+           (ui/menu-link options title nil)
+           title))])))
 
 ;; TODO: content could be changed
 ;; Also, keyboard bindings should only be activated after
@@ -423,17 +394,13 @@
 
 (rum/defc non-hiccup-content < rum/reactive
   [id content on-click on-hide config format]
-  (let [edit? (state/sub [:editor/editing? id])
-        loading (state/sub :format/loading)]
+  (let [edit? (state/sub [:editor/editing? id])]
     (if edit?
       (editor/box {:on-hide on-hide
                    :format format}
                   id
                   config)
-      (let [format (gp-util/normalize-format format)
-            loading? (get loading format)
-            markup? (contains? config/html-render-formats format)
-            on-click (fn [e]
+      (let [on-click (fn [e]
                        (when-not (util/link? (gobj/get e "target"))
                          (util/stop e)
                          (editor-handler/reset-cursor-range! (gdom/getElement (str id)))
@@ -441,17 +408,12 @@
                          (state/set-edit-input-id! id)
                          (when on-click
                            (on-click e))))]
-        (cond
-          (and markup? loading?)
-          [:div "loading ..."]
-
-          :else                       ; other text formats
-          [:pre.cursor.content.pre-white-space
-           {:id id
-            :on-click on-click}
-           (if (string/blank? content)
-             [:div.cursor "Click to edit"]
-             content)])))))
+        [:pre.cursor.content.pre-white-space
+         {:id id
+          :on-click on-click}
+         (if (string/blank? content)
+           [:div.cursor "Click to edit"]
+           content)]))))
 
 (defn- set-draw-iframe-style!
   []
@@ -466,16 +428,12 @@
           (d/set-style! draw :margin-left (str (- (/ (- width 570) 2)) "px")))))))
 
 (rum/defcs content < rum/reactive
-  {:will-mount (fn [state]
-                 (lazy-load-js state)
-                 state)
-   :did-mount (fn [state]
+  {:did-mount (fn [state]
                 (set-draw-iframe-style!)
                 (image-handler/render-local-images!)
                 state)
    :did-update (fn [state]
                  (set-draw-iframe-style!)
-                 (lazy-load-js state)
                  (image-handler/render-local-images!)
                  state)}
   [state id {:keys [format

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels