Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

11
deps/outliner/.carve/config.edn vendored Normal file
View File

@@ -0,0 +1,11 @@
{:paths ["src"]
:api-namespaces [logseq.outliner.datascript-report
logseq.outliner.pipeline
logseq.outliner.cli
logseq.outliner.core
logseq.outliner.db-pipeline
logseq.outliner.property
logseq.outliner.op.construct
logseq.outliner.recycle
logseq.outliner.tree]
:report {:format :ignore}}

14
deps/outliner/.carve/ignore vendored Normal file
View File

@@ -0,0 +1,14 @@
;; API fn
logseq.outliner.op/apply-ops!
;; API fn
logseq.outliner.op/register-op-handlers!
;; API fn
logseq.outliner.page/delete!
;; API fn
logseq.outliner.page/create!
;; Symbol
logseq.outliner.recycle/gc-interval-ms
;; API fn
logseq.outliner.recycle/restore!
;; API fn
logseq.outliner.recycle/gc!

16
deps/outliner/.clj-kondo/config.edn vendored Normal file
View File

@@ -0,0 +1,16 @@
{:linters
{:aliased-namespace-symbol {:level :warning}
:namespace-name-mismatch {:level :warning}
:used-underscored-binding {:level :warning}
:shadowed-var {:level :warning}
:consistent-alias
{:aliases {clojure.string string
logseq.outliner.core outliner-core
logseq.outliner.page outliner-page
logseq.outliner.op outliner-op
logseq.outliner.validate outliner-validate
logseq.outliner.pipeline outliner-pipeline
logseq.outliner.datascript-report ds-report}}}
:skip-comments true
:output {:progress true}}

1
deps/outliner/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.clj-kondo/.cache

50
deps/outliner/README.md vendored Normal file
View File

@@ -0,0 +1,50 @@
## Description
This library provides outliner operation related functionality. This library is
compatible with ClojureScript and with
node/[nbb-logseq](https://github.com/logseq/nbb-logseq) to respectively provide
frontend and commandline functionality.
## API
This library is under the parent namespace `logseq.outliner`. This library
provides two main namespaces: `logseq.outliner.datascript-report` and `logseq.outliner.pipeline`.
## Usage
See the frontend for cljs usage.
## Dev
This follows the practices that [the Logseq frontend
follows](/docs/dev-practices.md). Most of the same linters are used, with
configurations that are specific to this library. See [this library's CI
file](/.github/workflows/deps-outliner.yml) for linting examples.
### Setup
To run linters and tests, you'll want to install pnpm dependencies once:
```
pnpm install
```
This step is not needed if you're just running the frontend application.
### Testing
Testing is done with nbb-logseq and
[nbb-test-runner](https://github.com/nextjournal/nbb-test-runner). Some basic
usage:
```
# Run all tests
$ pnpm test
# List available options
$ pnpm test -H
# Run tests with :focus metadata flag
$ pnpm test -i focus
```
### Managing dependencies
See [standard nbb/cljs library advice in graph-parser](/deps/graph-parser/README.md#managing-dependencies).

32
deps/outliner/bb.edn vendored Normal file
View File

@@ -0,0 +1,32 @@
{:min-bb-version "1.0.168"
:deps
{logseq/bb-tasks
#_{:local/root "../../../bb-tasks"}
{:git/url "https://github.com/logseq/bb-tasks"
:git/sha "7cc218286c03f093996539b8195de56a0ec5df6a"}}
:pods
{clj-kondo/clj-kondo {:version "2026.04.15"}}
:tasks
{test:load-all-namespaces-with-nbb
logseq.bb-tasks.nbb.test/load-all-namespaces
lint:large-vars
logseq.bb-tasks.lint.large-vars/-main
lint:carve
logseq.bb-tasks.lint.carve/-main
lint:ns-docstrings
logseq.bb-tasks.lint.ns-docstrings/-main
lint:minimize-public-vars
logseq.bb-tasks.lint.minimize-public-vars/-main}
:tasks/config
{:large-vars
{:max-lines-count 55
;; TODO: Remove this once outliner.core-test is in dep and these
;; fns can be easily refactored
:metadata-exceptions #{:large-vars/cleanup-todo}}}}

19
deps/outliner/deps.edn vendored Normal file
View File

@@ -0,0 +1,19 @@
{:deps
;; These nbb-logseq deps are kept in sync with https://github.com/logseq/nbb-logseq/blob/main/bb.edn
{datascript/datascript {:git/url "https://github.com/logseq/datascript" ;; fork
:sha "3f141af97b70e1f14c65eaa119acd822ebece37e"}
;; datascript/datascript {:local/root "../../../../datascript"}
com.cognitect/transit-cljs {:mvn/version "0.8.280"}
;; Any other deps should be added here and to nbb.edn
logseq/db {:local/root "../db"}
logseq/graph-parser {:local/root "../graph-parser"}
metosin/malli {:git/url "https://github.com/metosin/malli"
:sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"}
;; stubbed via logseq.common.log
com.lambdaisland/glogi {:git/url "https://github.com/lambdaisland/glogi"
:git/sha "30328a045141717aadbbb693465aed55f0904976"}}
:aliases
{:clj-kondo
{:replace-deps {clj-kondo/clj-kondo {:mvn/version "2026.04.15"}}
:main-opts ["-m" "clj-kondo.main"]}}}

9
deps/outliner/nbb.edn vendored Normal file
View File

@@ -0,0 +1,9 @@
{:paths ["src"]
:deps
{logseq/db
{:local/root "../db"}
logseq/graph-parser
{:local/root "../graph-parser"}
metosin/malli {:git/url "https://github.com/metosin/malli"
:sha "52ea58a36ff5172b38dfc526ca638afa7226a4a0"}
io.github.nextjournal/nbb-test-runner {:git/sha "b379325cfa5a3306180649da5de3bf5166414e71"}}}

16
deps/outliner/package.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "@logseq/outliner",
"version": "1.0.0",
"packageManager": "pnpm@10.33.0",
"private": true,
"devDependencies": {
"@logseq/nbb-logseq": "git+https://github.com/logseq/nbb-logseq.git#feat-db-v34"
},
"dependencies": {
"better-sqlite3": "^12.8.0",
"mldoc": "^1.5.9"
},
"scripts": {
"test": "pnpm exec nbb-logseq -cp test -m nextjournal.test-runner"
}
}

691
deps/outliner/pnpm-lock.yaml generated vendored Normal file
View File

@@ -0,0 +1,691 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.8.0
version: 12.8.0
mldoc:
specifier: ^1.5.9
version: 1.5.9
devDependencies:
'@logseq/nbb-logseq':
specifier: git+https://github.com/logseq/nbb-logseq.git#feat-db-v34
version: https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb
packages:
'@logseq/nbb-logseq@https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb':
resolution: {tarball: https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb}
version: 1.2.173-feat-db-v34
engines: {node: '>=20.10.0'}
hasBin: true
ansi-regex@2.1.1:
resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
engines: {node: '>=0.10.0'}
ansi-regex@3.0.1:
resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==}
engines: {node: '>=4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
better-sqlite3@12.8.0:
resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
cliui@4.1.0:
resolution: {integrity: sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==}
code-point-at@1.1.0:
resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==}
engines: {node: '>=0.10.0'}
cross-spawn@6.0.6:
resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==}
engines: {node: '>=4.8'}
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
end-of-stream@1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
execa@1.0.0:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
get-caller-file@1.0.3:
resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==}
get-stream@4.1.0:
resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==}
engines: {node: '>=6'}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
import-meta-resolve@4.1.0:
resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
invert-kv@2.0.0:
resolution: {integrity: sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==}
engines: {node: '>=4'}
is-fullwidth-code-point@1.0.0:
resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@2.0.0:
resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==}
engines: {node: '>=4'}
is-stream@1.1.0:
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
engines: {node: '>=0.10.0'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
lcid@2.0.0:
resolution: {integrity: sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==}
engines: {node: '>=6'}
locate-path@3.0.0:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
map-age-cleaner@0.1.3:
resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==}
engines: {node: '>=6'}
mem@4.3.0:
resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==}
engines: {node: '>=6'}
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mldoc@1.5.9:
resolution: {integrity: sha512-87FQ7hseS87tsk+VdpIigpu8LH+GwmbbFgpxgFwvnbH5oOjmIrc47laH4Dyggzqiy8/vMjDHkl7vsId0eXhCDQ==}
hasBin: true
napi-build-utils@1.0.2:
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
nice-try@1.0.5:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
node-abi@3.71.0:
resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==}
engines: {node: '>=10'}
npm-run-path@2.0.2:
resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==}
engines: {node: '>=4'}
number-is-nan@1.0.1:
resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==}
engines: {node: '>=0.10.0'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
os-locale@3.1.0:
resolution: {integrity: sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==}
engines: {node: '>=6'}
p-defer@1.0.0:
resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==}
engines: {node: '>=4'}
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
p-is-promise@2.1.0:
resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==}
engines: {node: '>=6'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
path-key@2.0.1:
resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==}
engines: {node: '>=4'}
prebuild-install@7.1.2:
resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@1.0.1:
resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
shebang-regex@1.0.0:
resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
engines: {node: '>=0.10.0'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
string-width@1.0.2:
resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==}
engines: {node: '>=0.10.0'}
string-width@2.1.1:
resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==}
engines: {node: '>=4'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@3.0.1:
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
engines: {node: '>=0.10.0'}
strip-ansi@4.0.0:
resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==}
engines: {node: '>=4'}
strip-eof@1.0.0:
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
engines: {node: '>=0.10.0'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
tar-fs@2.1.1:
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
wrap-ansi@2.1.0:
resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==}
engines: {node: '>=0.10.0'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yargs-parser@11.1.1:
resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==}
yargs@12.0.5:
resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==}
snapshots:
'@logseq/nbb-logseq@https://codeload.github.com/logseq/nbb-logseq/tar.gz/4d9f1382ce2cec6f39bf12df49ba425f1bd5f3fb':
dependencies:
import-meta-resolve: 4.1.0
ansi-regex@2.1.1: {}
ansi-regex@3.0.1: {}
base64-js@1.5.1: {}
better-sqlite3@12.8.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.2
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
camelcase@5.3.1: {}
chownr@1.1.4: {}
cliui@4.1.0:
dependencies:
string-width: 2.1.1
strip-ansi: 4.0.0
wrap-ansi: 2.1.0
code-point-at@1.1.0: {}
cross-spawn@6.0.6:
dependencies:
nice-try: 1.0.5
path-key: 2.0.1
semver: 5.7.2
shebang-command: 1.2.0
which: 1.3.1
decamelize@1.2.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
deep-extend@0.6.0: {}
detect-libc@2.0.3: {}
end-of-stream@1.4.4:
dependencies:
once: 1.4.0
execa@1.0.0:
dependencies:
cross-spawn: 6.0.6
get-stream: 4.1.0
is-stream: 1.1.0
npm-run-path: 2.0.2
p-finally: 1.0.0
signal-exit: 3.0.7
strip-eof: 1.0.0
expand-template@2.0.3: {}
file-uri-to-path@1.0.0: {}
find-up@3.0.0:
dependencies:
locate-path: 3.0.0
fs-constants@1.0.0: {}
get-caller-file@1.0.3: {}
get-stream@4.1.0:
dependencies:
pump: 3.0.2
github-from-package@0.0.0: {}
ieee754@1.2.1: {}
import-meta-resolve@4.1.0: {}
inherits@2.0.4: {}
ini@1.3.8: {}
invert-kv@2.0.0: {}
is-fullwidth-code-point@1.0.0:
dependencies:
number-is-nan: 1.0.1
is-fullwidth-code-point@2.0.0: {}
is-stream@1.1.0: {}
isexe@2.0.0: {}
lcid@2.0.0:
dependencies:
invert-kv: 2.0.0
locate-path@3.0.0:
dependencies:
p-locate: 3.0.0
path-exists: 3.0.0
map-age-cleaner@0.1.3:
dependencies:
p-defer: 1.0.0
mem@4.3.0:
dependencies:
map-age-cleaner: 0.1.3
mimic-fn: 2.1.0
p-is-promise: 2.1.0
mimic-fn@2.1.0: {}
mimic-response@3.1.0: {}
minimist@1.2.8: {}
mkdirp-classic@0.5.3: {}
mldoc@1.5.9:
dependencies:
yargs: 12.0.5
napi-build-utils@1.0.2: {}
nice-try@1.0.5: {}
node-abi@3.71.0:
dependencies:
semver: 7.6.3
npm-run-path@2.0.2:
dependencies:
path-key: 2.0.1
number-is-nan@1.0.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
os-locale@3.1.0:
dependencies:
execa: 1.0.0
lcid: 2.0.0
mem: 4.3.0
p-defer@1.0.0: {}
p-finally@1.0.0: {}
p-is-promise@2.1.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@3.0.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
path-exists@3.0.0: {}
path-key@2.0.1: {}
prebuild-install@7.1.2:
dependencies:
detect-libc: 2.0.3
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 1.0.2
node-abi: 3.71.0
pump: 3.0.2
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.1
tunnel-agent: 0.6.0
pump@3.0.2:
dependencies:
end-of-stream: 1.4.4
once: 1.4.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
require-directory@2.1.1: {}
require-main-filename@1.0.1: {}
safe-buffer@5.2.1: {}
semver@5.7.2: {}
semver@7.6.3: {}
set-blocking@2.0.0: {}
shebang-command@1.2.0:
dependencies:
shebang-regex: 1.0.0
shebang-regex@1.0.0: {}
signal-exit@3.0.7: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
string-width@1.0.2:
dependencies:
code-point-at: 1.1.0
is-fullwidth-code-point: 1.0.0
strip-ansi: 3.0.1
string-width@2.1.1:
dependencies:
is-fullwidth-code-point: 2.0.0
strip-ansi: 4.0.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@3.0.1:
dependencies:
ansi-regex: 2.1.1
strip-ansi@4.0.0:
dependencies:
ansi-regex: 3.0.1
strip-eof@1.0.0: {}
strip-json-comments@2.0.1: {}
tar-fs@2.1.1:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.2
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.4
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
util-deprecate@1.0.2: {}
which-module@2.0.1: {}
which@1.3.1:
dependencies:
isexe: 2.0.0
wrap-ansi@2.1.0:
dependencies:
string-width: 1.0.2
strip-ansi: 3.0.1
wrappy@1.0.2: {}
y18n@4.0.3: {}
yargs-parser@11.1.1:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@12.0.5:
dependencies:
cliui: 4.1.0
decamelize: 1.2.0
find-up: 3.0.0
get-caller-file: 1.0.3
os-locale: 3.1.0
require-directory: 2.1.1
require-main-filename: 1.0.1
set-blocking: 2.0.0
string-width: 2.1.1
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 11.1.1

38
deps/outliner/script/transact.cljs vendored Normal file
View File

@@ -0,0 +1,38 @@
(ns transact
"This script generically runs transactions against the queried blocks"
(:require [clojure.edn :as edn]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.common.sqlite-cli :as sqlite-cli]
[logseq.db.frontend.rules :as rules]
[logseq.outliner.db-pipeline :as db-pipeline]
[nbb.core :as nbb]))
(defn -main [args]
(when (< (count args) 3)
(println "Usage: $0 GRAPH-DIR QUERY TRANSACT-FN")
(js/process.exit 1))
(let [[graph-dir query* transact-fn*] args
dry-run? (contains? (set args) "-n")
open-db-args (sqlite-cli/->open-db-args graph-dir)
db-name (if (= 1 (count open-db-args)) (first open-db-args) (second open-db-args))
conn (apply sqlite-cli/open-db! open-db-args)
;; find blocks to update
query (into (edn/read-string query*) [:in '$ '%]) ;; assumes no :in are in queries
transact-fn (edn/read-string transact-fn*)
blocks-to-update (mapv first (d/q query @conn (rules/extract-rules rules/db-query-dsl-rules)))
;; TODO: Use sci eval when it's available in nbb-logseq
update-tx (mapv (fn [id] (eval (list transact-fn id)))
blocks-to-update)]
(if dry-run?
(do (println "Would update" (count blocks-to-update) "blocks with the following tx:")
(prn update-tx)
(println "With the following blocks updated:")
(prn (map #(select-keys (d/entity @conn %) [:block/name :block/title]) blocks-to-update)))
(do
(db-pipeline/add-listener conn)
(ldb/transact! conn update-tx)
(println "Updated" (count update-tx) "block(s) for graph" (str db-name "!"))))))
(when (= nbb/*file* (nbb/invoked-file))
(-main *command-line-args*))

View File

@@ -0,0 +1,73 @@
(ns ^:node-only logseq.outliner.cli
"Primary ns for outliner CLI fns"
(:require ["child_process" :as child-process]
["fs" :as fs]
["path" :as node-path]
[borkdude.rewrite-edn :as rewrite]
[clojure.string :as string]
[logseq.db :as ldb]
[logseq.db.common.sqlite-cli :as sqlite-cli]
[logseq.db.sqlite.build :as sqlite-build]
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
[logseq.outliner.db-pipeline :as db-pipeline]))
(defn- find-on-classpath [classpath rel-path]
(some (fn [dir]
(let [f (node-path/join dir rel-path)]
(when (fs/existsSync f) f)))
(string/split classpath #":")))
(defn- pretty-print-merge
"Merge map into string while preversing whitespace"
[s m]
(-> (reduce (fn [acc [k v]]
(rewrite/assoc acc k v))
(rewrite/parse-string s)
m)
str))
(defn- get-git-sha
[]
(let [res (child-process/spawnSync "git"
#js ["rev-parse" "--short" "HEAD"]
#js {})]
(when (zero? (.-status res))
(string/trim (str (.-stdout res))))))
(defn- setup-init-data
"Setup initial data same as frontend.handler.repo/create-db"
[conn {:keys [additional-config classpath import-type]
:or {import-type :cli/default}}]
(let [config-content
(cond-> (or (some-> (find-on-classpath classpath "templates/config.edn") fs/readFileSync str)
(do (println "Setting graph's config to empty since no templates/config.edn was found.")
"{}"))
additional-config
(pretty-print-merge additional-config))
git-sha (get-git-sha)]
(ldb/transact! conn (sqlite-create-graph/build-db-initial-data config-content
(merge {:import-type import-type}
(when git-sha {:graph-git-sha git-sha}))))))
(defn init-conn
"Create sqlite DB, initialize datascript connection and sync listener and then
transacts initial data. Takes the following options:
* :additional-config - Additional config map to merge into repo config.edn
* :classpath - A java classpath string i.e. paths delimited by ':'. Used to find default config.edn
that comes with Logseq"
[& args*]
(let [[args opts] (if (map? (last args*))
[(butlast args*) (last args*)]
[args* {}])
;; Only mkdir when a dir and db-name are passed
_ (when (= 2 (count args))
(fs/mkdirSync (apply node-path/join args) #js {:recursive true}))
;; Same order as frontend.db.conn/start!
conn (apply sqlite-cli/open-db! args)]
(db-pipeline/add-listener conn)
(setup-init-data conn opts)
conn))
(def build-blocks-tx
"An alias for build-blocks-tx to specify default options for this ns"
sqlite-build/build-blocks-tx)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
(ns logseq.outliner.datascript
"Provides fns related to batch txs state")
(defn new-outliner-txs-state [] (atom []))
(defn outliner-txs-state?
[state]
(and
(instance? cljs.core/Atom state)
(coll? @state)))

View File

@@ -0,0 +1,45 @@
(ns logseq.outliner.datascript-report
"Datascript fns related to getting data from a connection listener's tx-report"
(:require [clojure.set :as set]
[datascript.core :as d]))
(def ^:private keys-of-deleted-entity 1)
(defn- get-entity-from-db-after-or-before
"Get the entity from db after if possible; otherwise get entity from db before
Useful for fetching deleted elements"
[db-before db-after db-id]
(let [r (d/pull db-after '[*] db-id)]
(if (= keys-of-deleted-entity (count r))
;; block has been deleted
(d/pull db-before '[*] db-id)
r)))
(defn get-blocks-and-pages
"Calculate updated blocks and pages based on the db-before and db-after from tx-report"
[{:keys [db-before db-after tx-data tx-meta]}]
(let [updated-db-ids (-> (mapv first tx-data) (set))
result (reduce
(fn [acc x]
(let [block-entity
(get-entity-from-db-after-or-before db-before db-after x)
page-entity
(when-let [page-id (-> block-entity :block/page :db/id)]
(get-entity-from-db-after-or-before db-before db-after page-id))]
(cond-> acc
(some? block-entity)
(update :blocks conj block-entity)
(some? page-entity)
(update :pages conj page-entity))))
{:blocks #{}
:pages #{}}
updated-db-ids)
;; updated pages logged in tx-meta (usually from move op)
tx-meta-pages (->> [(:from-page tx-meta) (:target-page tx-meta)]
(remove nil?)
(map #(get-entity-from-db-after-or-before db-before db-after %))
(set))]
(if (seq tx-meta-pages)
(update result :pages set/union tx-meta-pages)
result)))

View File

@@ -0,0 +1,23 @@
(ns logseq.outliner.db-pipeline
"This ns provides a datascript listener for DB graphs that is useful for CLIs
and testing (since it doesn't assume a frontend worker exists). The listener adds
additional changes that the frontend also adds per transact. Missing features
from frontend.worker.pipeline including:
* Deleted blocks don't update effected :block/tx-id
* Delete empty property parent"
(:require [datascript.core :as d]
[logseq.outliner.pipeline :as outliner-pipeline]))
(defn- invoke-hooks
"Modified copy of frontend.worker.pipeline/invoke-hooks that handles new DB graphs but
doesn't handle updating DB graphs well yet e.g. doesn't handle :block/tx-id"
[conn tx-report]
(when-not (:transact-new-graph-refs? (:tx-meta tx-report))
;; TODO: Handle block edits with separate :block/refs rebuild as deleting property values is buggy
(outliner-pipeline/transact-new-db-graph-refs conn tx-report)))
(defn ^:api add-listener
"Adds a listener to the datascript connection to add additional changes from outliner.pipeline"
[conn]
(d/listen! conn :pipeline-updates (fn pipeline-updates [tx-report]
(invoke-hooks conn tx-report))))

View File

@@ -0,0 +1,450 @@
(ns logseq.outliner.op
"Transact outliner ops"
(:require [clojure.string :as string]
[datascript.core :as d]
[logseq.common.defkeywords :refer [defkeywords]]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.db :as ldb]
[logseq.db.sqlite.export :as sqlite-export]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.template :as outliner-template]
[logseq.outliner.page :as outliner-page]
[logseq.outliner.property :as outliner-property]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.outliner.transaction :as outliner-tx]
[malli.core :as m]
[logseq.outliner.op.construct :as op-construct]))
(defkeywords
::missing-parent-original {:doc "The linked-reference parent supplied to an outdent no longer exists."})
(def ^:private ^:large-vars/data-var op-schema
[:multi {:dispatch first}
;; blocks
[:save-block
[:catn
[:op :keyword]
[:args [:tuple ::block ::option]]]]
[:insert-blocks
[:catn
[:op :keyword]
[:args [:tuple ::blocks ::block-id ::option]]]]
[:apply-template
[:catn
[:op :keyword]
[:args [:tuple ::block-id ::block-id ::option]]]]
[:delete-blocks
[:catn
[:op :keyword]
[:args [:tuple ::block-ids ::option]]]]
[:move-blocks
[:catn
[:op :keyword]
[:args [:tuple ::block-ids ::block-id ::option]]]]
[:move-blocks-up-down
[:catn
[:op :keyword]
[:args [:tuple ::block-ids :boolean]]]]
[:indent-outdent-blocks
[:catn
[:op :keyword]
[:args [:tuple ::block-ids :boolean ::option]]]]
[:collapse-expand-blocks
[:catn
[:op :keyword]
[:args [:tuple ::blocks ::option]]]]
;; properties
[:upsert-property
[:catn
[:op :keyword]
[:args [:tuple ::maybe-property-id ::schema ::option]]]]
[:set-block-property
[:catn
[:op :keyword]
[:args [:tuple ::block-id ::property-id ::value]]]]
[:remove-block-property
[:catn
[:op :keyword]
[:args [:tuple ::block-id ::property-id]]]]
[:delete-property-value
[:catn
[:op :keyword]
[:args [:tuple ::block-id ::property-id ::value]]]]
[:batch-delete-property-value
[:catn
[:op :keyword]
[:args [:tuple ::block-ids ::property-id ::value]]]]
[:create-property-text-block
[:catn
[:op :keyword]
[:args [:tuple ::maybe-block-id ::property-id ::value ::option]]]]
[:collapse-expand-block-property
[:catn
[:op :keyword]
[:args [:tuple ::block-id ::property-id :boolean]]]]
[:batch-set-property
[:catn
[:op :keyword]
[:args [:tuple ::block-ids ::property-id ::value ::option]]]]
[:batch-remove-property
[:catn
[:op :keyword]
[:args [:tuple ::block-ids ::property-id]]]]
[:class-add-property
[:catn
[:op :keyword]
[:args [:tuple ::class-id ::property-id]]]]
[:class-remove-property
[:catn
[:op :keyword]
[:args [:tuple ::class-id ::property-id]]]]
[:upsert-closed-value
[:catn
[:op :keyword]
[:args [:tuple ::property-id ::option]]]]
[:delete-closed-value
[:catn
[:op :keyword]
[:args [:tuple ::property-id ::block-id]]]]
[:add-existing-values-to-closed-values
[:catn
[:op :keyword]
[:args [:tuple ::property-id ::values]]]]
[:batch-import-edn
[:catn
[:op :keyword]
[:args [:tuple ::import-edn ::option]]]]
;; transact
[:transact
[:catn
[:op :keyword]
[:args [:tuple ::tx-data ::tx-meta]]]]
;; page ops
[:create-page
[:catn
[:op :keyword]
[:args [:tuple ::title ::option]]]]
[:rename-page
[:catn
[:op :keyword]
[:args [:tuple ::uuid ::title]]]]
[:delete-page
[:catn
[:op :keyword]
[:args [:tuple ::uuid ::option]]]]
[:restore-recycled
[:catn
[:op :keyword]
[:args [:tuple ::uuid]]]]
[:recycle-delete-permanently
[:catn
[:op :keyword]
[:args [:tuple ::uuid]]]]
[:toggle-reaction
[:catn
[:op :keyword]
[:args [:tuple ::uuid ::emoji-id ::maybe-uuid]]]]])
(def ^:private ops-schema
[:schema {:registry {::block map?
::schema map?
::block-id uuid?
::maybe-block-id [:maybe ::block-id]
::block-ids [:sequential ::block-id]
::class-id ::block-id
::emoji-id string?
::property-id qualified-keyword?
::maybe-property-id [:maybe ::property-id]
::maybe-uuid [:maybe :uuid]
::value :any
::values [:sequential ::value]
::option [:maybe map?]
::import-edn map?
::blocks [:sequential ::block]
::uuid uuid?
::title string?
::tx-data [:sequential :any]
::tx-meta [:maybe map?]}}
[:sequential op-schema]])
(def ^:private ops-validator (m/validator ops-schema))
(defn- reaction-user-id
[reaction]
(:db/id (:logseq.property/created-by-ref reaction)))
(defn- toggle-reaction!
[conn target-uuid emoji-id user-uuid]
(when-let [target (d/entity @conn [:block/uuid target-uuid])]
(let [user-id (when user-uuid
(:db/id (d/entity @conn [:block/uuid user-uuid])))
reactions (:logseq.property.reaction/_target target)
match? (fn [reaction]
(and (= emoji-id (:logseq.property.reaction/emoji-id reaction))
(if user-id
(= user-id (reaction-user-id reaction))
(nil? (reaction-user-id reaction)))))
existing (some (fn [reaction] (when (match? reaction) reaction)) reactions)]
(if existing
(do
(ldb/transact! conn [[:db/retractEntity (:db/id existing)]]
{:outliner-op :toggle-reaction})
true)
(let [now (common-util/time-ms)
reaction-tx (cond-> {:block/uuid (d/squuid)
:block/created-at now
:logseq.property.reaction/emoji-id emoji-id
:logseq.property.reaction/target (:db/id target)}
user-id
(assoc :logseq.property/created-by-ref user-id))]
(ldb/transact! conn [reaction-tx]
{:outliner-op :toggle-reaction})
true)))))
(defn- import-edn-data
[conn *result export-map {:keys [tx-meta] :as import-options}]
(let [{:keys [error] :as txs}
(try (sqlite-export/build-import export-map @conn (dissoc import-options :tx-meta))
(catch :default e
(js/console.error "Import EDN error: " e)
{:error "An unexpected error occurred building the import. See the javascript console for details."}))
validation (when-not error
(sqlite-export/validate-import-txs txs @conn))]
;; (cljs.pprint/pprint txs)
(if (or error (:error validation))
(reset! *result {:error (or error (:error validation))})
(try
(ldb/transact! conn (:tx-data validation)
(merge {::sqlite-export/imported-data? true} tx-meta))
(catch :default e
(js/console.error "Unexpected Import EDN error:" e)
(reset! *result {:error (str "Unexpected Import EDN error: " (pr-str (ex-message e)))}))))))
(defn- apply-insert-blocks-op!
[conn *result [blocks target-block-id opts]]
(when-let [target-block (d/entity @conn [:block/uuid target-block-id])]
(let [result (outliner-core/insert-blocks! conn blocks target-block opts)]
(reset! *result result))))
(defn- template-children-blocks
[db template-id]
(when-let [template (d/entity db template-id)]
(let [template-blocks (some->> (ldb/get-block-and-children db (:block/uuid template)
{:include-property-block? true})
rest)]
(when (seq template-blocks)
(cons (assoc (into {} (first template-blocks))
:db/id (:db/id (first template-blocks))
:logseq.property/used-template (:db/id template))
(map (fn [block]
(assoc (into {} block) :db/id (:db/id block)))
(rest template-blocks)))))))
(defn- journal-title
[db journal-day]
(date-time-util/int->journal-title
journal-day
(:logseq.property.journal/title-format (d/entity db :logseq.class/Journal))))
(defn- ensure-template-journal-pages!
[conn blocks]
(doseq [journal-day (outliner-template/dynamic-template-journal-days blocks)]
(when-not (ldb/get-journal-page-by-day @conn journal-day)
(outliner-page/create! conn (journal-title @conn journal-day) {:journal? true}))))
(defn- apply-template-op!
[conn *result [template-id target-block-id opts]]
(when-let [target (d/entity @conn [:block/uuid target-block-id])]
(let [blocks (or (some-> (:template-blocks opts) seq vec)
(template-children-blocks @conn [:block/uuid template-id]))
_ (ensure-template-journal-pages! conn blocks)
blocks (outliner-template/resolve-dynamic-template-blocks @conn target blocks)]
(when (seq blocks)
(let [sibling? (:sibling? opts)
sibling?' (cond
(some? sibling?)
sibling?
(seq (:block/_parent target))
false
:else
true)
result (outliner-core/insert-blocks! conn blocks target
(assoc opts
:sibling? sibling?'
:insert-template? true
:outliner-op :insert-template-blocks))]
(reset! *result result))))))
(defn- resolve-indent-outdent-opts
[db opts]
(if-let [parent-original-uuid (get-in opts [:parent-original :block/uuid])]
(let [parent-original (or (d/entity db [:block/uuid parent-original-uuid])
(throw (ex-info "Missing outdent parent original"
{:type ::missing-parent-original
:block/uuid parent-original-uuid})))]
(assoc opts :parent-original parent-original))
opts))
(defn- ^:large-vars/cleanup-todo apply-op!
[conn opts' *result [op args]]
(case op
;; blocks
:save-block
(apply outliner-core/save-block! conn args)
:insert-blocks
(apply-insert-blocks-op! conn *result args)
:apply-template
(apply-template-op! conn *result args)
:delete-blocks
(let [[block-ids opts] args
blocks (keep #(d/entity @conn [:block/uuid %]) block-ids)]
(outliner-core/delete-blocks! conn blocks (merge opts opts')))
:move-blocks
(let [[block-ids target-block-id opts] args
blocks (keep #(d/entity @conn [:block/uuid %]) block-ids)
target-block (d/entity @conn [:block/uuid target-block-id])]
(when (and target-block (seq blocks))
(outliner-core/move-blocks! conn blocks target-block opts)))
:move-blocks-up-down
(let [[block-ids up?] args
blocks (keep #(d/entity @conn [:block/uuid %]) block-ids)]
(when (seq blocks)
(outliner-core/move-blocks-up-down! conn blocks up?)))
:indent-outdent-blocks
(let [[block-ids indent? opts] args
blocks (keep #(d/entity @conn [:block/uuid %]) block-ids)
opts (resolve-indent-outdent-opts @conn opts)]
(when (seq blocks)
(outliner-core/indent-outdent-blocks! conn blocks indent? opts)))
:collapse-expand-blocks
(let [[blocks opts] args]
(ldb/transact! conn blocks opts))
;; properties
:upsert-property
(reset! *result (apply outliner-property/upsert-property! conn args))
:set-block-property
(apply outliner-property/set-block-property! conn args)
:remove-block-property
(apply outliner-property/remove-block-property! conn args)
:delete-property-value
(apply outliner-property/delete-property-value! conn args)
:create-property-text-block
(let [[block-id property-id v opts] args
block-id' (when block-id [:block/uuid block-id])]
(outliner-property/create-property-text-block! conn block-id' property-id v opts))
:batch-set-property
(apply outliner-property/batch-set-property! conn args)
:batch-remove-property
(apply outliner-property/batch-remove-property! conn args)
:batch-delete-property-value
(apply outliner-property/batch-delete-property-value! conn args)
:class-add-property
(let [[class-id property-id] args]
(outliner-property/class-add-property! conn [:block/uuid class-id] property-id))
:class-remove-property
(let [[class-id property-id] args]
(outliner-property/class-remove-property! conn [:block/uuid class-id] property-id))
:upsert-closed-value
(apply outliner-property/upsert-closed-value! conn args)
:delete-closed-value
(let [[property-id value-block-id] args]
(outliner-property/delete-closed-value! conn property-id [:block/uuid value-block-id]))
:add-existing-values-to-closed-values
(apply outliner-property/add-existing-values-to-closed-values! conn args)
:batch-import-edn
(apply import-edn-data conn *result args)
:transact
(apply ldb/transact! conn args)
:create-page
(let [[title options] args]
(reset! *result (outliner-page/create! conn title (or options {}))))
:rename-page
(let [[page-uuid new-title] args]
(if (string/blank? new-title)
(throw (ex-info "Page name shouldn't be blank" {:block/uuid page-uuid
:block/title new-title}))
(outliner-core/save-block! conn
{:block/uuid page-uuid
:block/title new-title})))
:delete-page
(let [[page-uuid opts] args]
(reset! *result (outliner-page/delete! conn page-uuid (merge opts opts'))))
:restore-recycled
(let [[root-uuid] args]
(reset! *result (outliner-recycle/restore! conn root-uuid)))
:recycle-delete-permanently
(let [[root-uuid] args]
(reset! *result (outliner-recycle/permanently-delete! conn root-uuid)))
:toggle-reaction
(reset! *result (apply toggle-reaction! conn args))
nil))
(defn- import-edn-op?
[[op _args]]
(= :batch-import-edn op))
(defn apply-ops!
[conn ops opts]
(assert (ops-validator ops) ops)
(let [semantic-ops (filter (fn [op] (get op-construct/semantic-outliner-ops (first op))) ops)
single-op-outliner-op (when (= 1 (count ops))
(first (first ops)))
opts' (cond-> (assoc opts
:transact-opts {:conn conn}
:local-tx? true
:outliner-ops semantic-ops
:db-sync/tx-id (or (:db-sync/tx-id opts) (random-uuid)))
(and single-op-outliner-op
(nil? (:outliner-op opts)))
(assoc :outliner-op single-op-outliner-op)
(some import-edn-op? ops)
(assoc ::sqlite-export/imported-data? true))
*result (atom nil)]
(outliner-tx/transact!
opts'
(doseq [op-entry ops]
(apply-op! conn opts' *result op-entry)))
@*result))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
(ns logseq.outliner.page
"Page-related fns for DB graphs"
(:require [clojure.string :as string]
[datascript.core :as d]
[datascript.impl.entity :as de]
[logseq.common.config :as common-config]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.util.namespace :as ns-util]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.malli-schema :as db-malli-schema]
[logseq.db.frontend.property :as db-property]
[logseq.db.frontend.property.build :as db-property-build]
[logseq.graph-parser.block :as gp-block]
[logseq.graph-parser.text :as text]
[logseq.outliner.recycle :as outliner-recycle]
[logseq.outliner.tx-meta :as outliner-tx-meta]
[logseq.outliner.validate :as outliner-validate]))
(defn- page-ref-rewrite-targets
"Collect entities that reference `page-entity` via node refs and need title rewrite."
[page-entity]
(let [refs (->> (:block/_refs page-entity)
;; remove child or self that refed this page
(remove (fn [ref]
(or (= (:db/id ref) (:db/id page-entity))
(= (:db/id (:block/page ref)) (:db/id page-entity))))))
id-ref->page #(db-content/content-id-ref->page % [page-entity])]
(->> refs
(keep (fn [ref]
(let [raw-title (:block/raw-title ref)
block-uuid (:block/uuid ref)]
(when raw-title
(let [content' (id-ref->page raw-title)]
(when (not= raw-title content')
(let [remaining-refs (->> (:block/refs ref)
(remove (fn [ref']
(= (:db/id ref') (:db/id page-entity))))
vec)]
{:ref-id (:db/id ref)
:ref-uuid block-uuid
:title content'
:refs remaining-refs})))))))
seq)))
(defn- db-refs->page
"Replace [[page name]] with page name."
[page-entity]
(let [page-id (:db/id page-entity)]
(some->> (page-ref-rewrite-targets page-entity)
(mapcat (fn [{:keys [ref-id title]}]
[[:db/retract ref-id :block/refs page-id]
{:db/id ref-id
:block/title title}])))))
(defn- db-refs->page-save-ops
[page-entity]
(some->> (page-ref-rewrite-targets page-entity)
(keep (fn [{:keys [ref-uuid title refs]}]
(when ref-uuid
[:save-block [{:block/uuid ref-uuid
:block/title title
:block/refs refs}
{}]])))
seq
vec))
(defn ^:api build-page-retract-tx
"Build cleanup tx-data for deleting a schema page.
This is pure and can be reused by sync repair."
[db page & [{:keys [include-page-retract? today-page?]
:or {include-page-retract? true}}]]
(let [page-id (:db/id page)
page-blocks-tx-data (->> (:block/_page page)
(keep (fn [block]
(when (d/entity db [:block/uuid (:block/uuid block)])
[:db/retractEntity [:block/uuid (:block/uuid block)]]))))
property-pair-tx-data (when (ldb/property? page)
(->> (d/datoms db :avet (:db/ident page))
(map (fn [d] [:db/retract (:e d) (:a d) (:v d)]))))
restore-class-parent-tx (when (ldb/class? page)
(->> (filter ldb/class? (:logseq.property.class/_extends page))
(map (fn [p]
{:db/id (:db/id p)
:logseq.property.class/extends :logseq.class/Root}))))
page-tx (when (and include-page-retract?
(d/entity db page-id))
[[:db/retractEntity page-id]])]
(if today-page?
page-blocks-tx-data
(concat page-blocks-tx-data
property-pair-tx-data
restore-class-parent-tx
(db-refs->page page)
page-tx))))
(defn delete!
"Deletes a page. Returns true if able to delete page. If unable to delete,
calls error-handler fn and returns false.
Rules:
1. today page content is truncated but the page itself can't be deleted
2. properties and tags will be hard retracted
3. other pages will be moved to Recycle"
[conn page-uuid & {:keys [persist-op? rename? error-handler deleted-by-uuid now-ms]
:or {persist-op? true
error-handler (fn [{:keys [msg]}] (js/console.error msg))}}]
(assert (uuid? page-uuid) (str ::delete! " wrong page-uuid: " (if page-uuid page-uuid "nil")))
(when page-uuid
(when-let [page (d/entity @conn [:block/uuid page-uuid])]
(let [today-page? (when-let [day (:block/journal-day page)]
(= (date-time-util/ms->journal-day (js/Date.)) day))
tx-meta (cond-> (outliner-tx-meta/ensure-outliner-ops
{:outliner-op :delete-page
:deleted-page (:block/title page)
:persist-op? persist-op?}
[:delete-page [page-uuid {:deleted-by-uuid deleted-by-uuid
:now-ms now-ms}]])
rename?
(assoc :source-outliner-op :rename-page))]
;; TODO: maybe we should add $$$favorites to built-in pages?
(cond
(or (ldb/built-in? page) (ldb/hidden? page))
(do
(error-handler {:msg "Built-in page cannot be deleted"})
false)
today-page?
(let [tx-data (build-page-retract-tx @conn page {:today-page? true})]
(when (seq tx-data)
(ldb/transact! conn tx-data tx-meta))
{:truncated? true})
(or (ldb/class? page) (ldb/property? page))
(let [tx-data (build-page-retract-tx @conn page {})]
(ldb/transact! conn tx-data tx-meta)
true)
:else
(let [ref-rewrite-tx-data (db-refs->page page)
ref-rewrite-save-ops (db-refs->page-save-ops page)
tx-data (concat ref-rewrite-tx-data
(outliner-recycle/recycle-page-tx-data @conn page {:deleted-by-uuid deleted-by-uuid
:now-ms now-ms}))
tx-meta' (cond-> tx-meta
(seq ref-rewrite-save-ops)
(update :outliner-ops (fnil into []) ref-rewrite-save-ops))]
(when (seq tx-data)
(ldb/transact! conn tx-data tx-meta'))
true))))))
(defn- build-page-tx [db properties page {:keys [class? tags class-ident-namespace]}]
(when (:block/uuid page)
(let [type-tag (if class? :logseq.class/Tag :logseq.class/Page)
tags' (if (:block/journal-day page) tags (conj tags type-tag))
page' (update page :block/tags
(fnil into [])
(mapv (fn [tag]
(let [v (if (uuid? tag)
(d/entity db [:block/uuid tag])
tag)]
(cond (de/entity? v)
(:db/id v)
;; tx map
(map? v)
;; Handle adding :db/ident if a new tag
(if (d/entity db [:block/uuid (:block/uuid v)])
v
(db-class/build-new-class db v))
:else
v)))
tags'))
property-vals-tx-m
;; Builds property values for built-in properties like logseq.property.pdf/file
(db-property-build/build-property-values-tx-m
page'
(->> properties
(keep (fn [[k v]]
;; TODO: Pass in property type in order to support property
;; types other than :default
(when (db-property/built-in-has-ref-value? k)
[k v])))
(into {})))]
(cond-> (if class?
[(merge (db-class/build-new-class db page' {:ident-namespace class-ident-namespace})
;; FIXME: new pages shouldn't have db/ident but converting property to tag still relies on this
(select-keys page' [:db/ident]))
[:db/retract [:block/uuid (:block/uuid page)] :block/tags :logseq.class/Page]]
[page'])
(seq property-vals-tx-m)
(into (vals property-vals-tx-m))
true
(conj (merge {:block/uuid (:block/uuid page)}
properties
(db-property-build/build-properties-with-ref-values property-vals-tx-m)))))))
;; TODO: Revisit title cleanup as this was copied from file implementation
(defn ^:api sanitize-title
[title]
(let [title (-> (string/trim title)
(text/page-ref-un-brackets!))
title (common-util/remove-boundary-slashes title)]
title))
(defn- get-page-by-parent-name
[db parent-title child-title class?]
(some->>
(d/q
'[:find [?b ...]
:in $ ?attribute ?parent-name ?child-name
:where
[?b ?attribute ?p]
[?b :block/name ?child-name]
[?p :block/name ?parent-name]]
db
(if class? :logseq.property.class/extends :block/parent)
(common-util/page-name-sanity-lc parent-title)
(common-util/page-name-sanity-lc child-title))
first
(d/entity db)))
(defn- page-with-parent-and-order
"Apply to namespace pages"
[db page & {:keys [parent]}]
(let [library (ldb/get-built-in-page db common-config/library-page-name)]
(when (nil? library)
(throw (ex-info "Library page doesn't exist" {})))
(assoc page
:block/parent (or parent (:db/id library))
:block/order (db-order/gen-key))))
(defn- ^:large-vars/cleanup-todo split-namespace-pages
[db page date-formatter create-class?]
(let [{:block/keys [title] block-uuid :block/uuid} page]
(->>
(if (and (or (entity-util/class? page)
(entity-util/page? page))
(ns-util/namespace-page? title))
(let [class? (entity-util/class? page)
parts (->> (string/split title ns-util/parent-re)
(map string/trim)
(remove string/blank?))
pages (map-indexed
(fn [idx part]
(let [last-part? (= idx (dec (count parts)))
page (if (zero? idx)
(ldb/get-page db part)
(get-page-by-parent-name db (nth parts (dec idx)) part create-class?))
result (or page
(gp-block/page-name->map part db true date-formatter
{:page-uuid (when last-part? block-uuid)
:skip-existing-page-check? true
:class? class?}))]
result))
parts)]
(cond
(and (not class?) (not (every? ldb/internal-page? pages)))
(throw (ex-info "Cannot create this page unless all parents are pages"
{:type :notification
:payload {:message "Cannot create this page unless all parents are pages."
:i18n-key :page.validation/parents-must-be-pages
:type :warning}}))
(and class? (not (every? ldb/class? pages)))
(throw (ex-info "Cannot create this tag unless all parents are tags"
{:type :notification
:payload {:message "Cannot create this tag unless all parents are tags."
:i18n-key :class.validation/parents-must-be-tags
:type :warning}}))
:else
(map-indexed
(fn [idx page]
(let [parent-eid (when (> idx 0)
(when-let [id (:block/uuid (nth pages (dec idx)))]
[:block/uuid id]))]
(if class?
(cond
(and (de/entity? page) (ldb/class? page))
(assoc page :logseq.property.class/extends parent-eid)
(de/entity? page) ; page exists but not a class, avoid converting here because this could be troublesome.
nil
(zero? idx)
(db-class/build-new-class db page)
:else
(db-class/build-new-class db (assoc page :logseq.property.class/extends parent-eid)))
(if (de/entity? page)
page
(page-with-parent-and-order db page {:parent parent-eid})))))
pages)))
[page])
(remove nil?))))
(defn ^:large-vars/cleanup-todo ^:api create
"Pure function without side effects"
[db title*
{uuid' :uuid
:keys [tags properties persist-op?
class? journal? today-journal? split-namespace? class-ident-namespace]
:or {properties nil
persist-op? true}
:as options}]
(let [date-formatter (:logseq.property.journal/title-format (entity-plus/entity-memoized db :logseq.class/Journal))
tags (if (every? uuid? tags)
(map (fn [id] (d/entity db [:block/uuid id])) tags)
tags)
class? (or class? (some (fn [t] (= :logseq.class/Tag (:db/ident t))) tags))
class-ident-namespace? (and class? class-ident-namespace (string? class-ident-namespace))
title (sanitize-title title*)
_ (outliner-validate/validate-page-title-no-hashtag title {:node {:block/title title}})
types (cond class?
#{:logseq.class/Tag}
(or journal? today-journal?)
#{:logseq.class/Journal}
(seq tags)
(set (map :db/ident tags))
:else
#{:logseq.class/Page})
existing-names-page (ldb/page-exists? db title types)
journal-page-uuid (some-> (gp-block/page-name->map title db false date-formatter
{:class? class?
:skip-existing-page-check? true})
:block/uuid)
existing-page-by-journal-uuid (when (uuid? journal-page-uuid)
(d/entity db [:block/uuid journal-page-uuid]))
existing-page-id (if class-ident-namespace?
(some->> existing-names-page
(filter #(try (when-let [e (d/entity db %)]
(let [ns' (namespace (:db/ident e))]
(= (str ns') class-ident-namespace)))
(catch :default _ false)))
(first))
(first existing-names-page))
existing-page (or (some->> existing-page-id (d/entity db))
existing-page-by-journal-uuid)]
(if (and existing-page
(or (:block/journal-day existing-page)
(not (:block/parent existing-page))
(ldb/recycled? existing-page)))
(let [tx-meta {:persist-op? persist-op?
:outliner-op :save-block}]
(cond
(and class?
(not (ldb/class? existing-page))
(ldb/internal-page? existing-page))
;; Convert existing page to class
(let [tx-data [(merge (db-class/build-new-class db
(select-keys existing-page [:block/title :block/uuid :block/created-at])
(when class-ident-namespace?
{:ident-namespace class-ident-namespace}))
(select-keys existing-page [:db/ident]))
[:db/retract [:block/uuid (:block/uuid existing-page)] :block/tags :logseq.class/Page]]]
{:tx-meta tx-meta
:tx-data tx-data
:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)})
(ldb/recycled? existing-page)
(let [options' (assoc options :uuid (:block/uuid existing-page))
tx-meta' (outliner-tx-meta/ensure-outliner-ops
{:persist-op? persist-op?
:outliner-op :create-page}
[:create-page [title options']])]
{:tx-meta tx-meta'
:tx-data (outliner-recycle/restore-tx-data db existing-page)
:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)})
;; Just return existing page info
:else
{:page-uuid (:block/uuid existing-page)
:title (:block/title existing-page)}))
(let [page (gp-block/page-name->map title db true date-formatter
{:class? class?
:page-uuid (when (uuid? uuid') uuid')
:skip-existing-page-check? true})
[page parents'] (if (and (not (:block/journal-day page))
(text/namespace-page? title)
split-namespace?)
(let [pages (split-namespace-pages db page date-formatter class?)]
[(last pages) (butlast pages)])
[page nil])]
(when (and page (or (nil? (:db/ident page))
;; New page creation must not override built-in entities
(not (db-malli-schema/internal-ident? (:db/ident page)))))
;; Don't validate journal names because they can have '/'
(when-not (or (contains? types :logseq.class/Journal)
(contains? (set (:block/tags page)) :logseq.class/Journal))
(outliner-validate/validate-page-title-characters (str (:block/title page)) {:node page})
(doseq [parent parents']
(outliner-validate/validate-page-title-characters (str (:block/title parent)) {:node parent})))
(let [page-uuid (if-let [journal-day (:block/journal-day page)]
(common-uuid/gen-uuid :journal-page-uuid journal-day)
(:block/uuid page))
page (assoc page :block/uuid page-uuid)
page-txs (build-page-tx db properties page (select-keys options [:class? :tags :class-ident-namespace]))
txs (concat
;; transact doesn't support entities
(remove de/entity? parents')
page-txs)
tx-meta (cond-> (outliner-tx-meta/ensure-outliner-ops
{:persist-op? persist-op?
:outliner-op :create-page}
[:create-page [title options]])
today-journal?
(assoc :create-today-journal? true
:today-journal-name title))]
{:tx-meta tx-meta
:tx-data txs
:title title
:page-uuid page-uuid}))))))
(defn create!
[conn title opts]
(let [{:keys [tx-meta tx-data title page-uuid]} (create @conn title opts)]
(when (seq tx-data)
(ldb/transact! conn tx-data tx-meta))
[title page-uuid]))

View File

@@ -0,0 +1,156 @@
(ns logseq.outliner.pipeline
"Core fns for use with frontend worker and node"
(:require [datascript.core :as d]
[datascript.impl.entity :as de]
[logseq.common.util.date-time :as date-time-util]
[logseq.db :as ldb]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.frontend.content :as db-content]
[logseq.db.frontend.property :as db-property]
[logseq.outliner.datascript-report :as ds-report]
[clojure.set :as set]))
(defn filter-deleted-blocks
[datoms]
(keep
(fn [d]
(when (and (= :block/uuid (:a d)) (false? (:added d)))
{:db/id (:e d)
:block/uuid (:v d)}))
datoms))
(defn- ref->eid
"ref: entity, map, int, eid"
[ref]
(cond
(:db/id ref)
(:db/id ref)
(:block/uuid ref)
[:block/uuid (:block/uuid ref)]
(and (vector? ref)
(= (count ref) 2)
(= :block/uuid (first ref)))
[:block/uuid (second ref)]
(int? ref)
ref
:else (throw (js/Error. (str "invalid ref " ref)))))
(defn block-content-refs
"Return ref block ids for given block"
[db block]
(let [content (or (:block/raw-title block)
(:block/title block))]
(when (string? content)
(->> (db-content/get-matched-ids content)
(map (fn [id]
(when-let [e (d/entity db [:block/uuid id])]
(:db/id e))))))))
(defn ^:api get-journal-day-from-long
[db v]
(when v
(let [day (date-time-util/ms->journal-day v)]
(:e (first (d/datoms db :avet :block/journal-day day))))))
(def ^:private private-built-in-props (set (keep (fn [[k v]] (when-not (get-in v [:schema :public?]) k))
db-property/built-in-properties)))
(defn- build-journal-refs-for-datetime-properties
"For a given property pair, builds a coll of journal refs for select built-in
:datetime properties and all user :datetime properties. Otherwise returns nil"
[db property v]
(let [property-ent (d/entity db property)
allowed-datetime? (and (= :datetime (:logseq.property/type property-ent))
;; Only allow a few built-in properties as some built-in properties
;; can create undesirable refs
(if (db-property/internal-property? (:db/ident property-ent))
(contains? #{:logseq.property/scheduled :logseq.property/deadline} (:db/ident property-ent))
;; All user properties are allowed to create refs but not plugin properties
(not (db-property/plugin-property? (:db/ident property-ent)))))]
(cond
(and allowed-datetime? (coll? v))
(keep #(get-journal-day-from-long db %) v)
allowed-datetime?
(when-let [journal-day (get-journal-day-from-long db v)]
[journal-day])
:else
nil)))
(defn ^:api page-or-object?-helper
[block]
(and (de/entity? block)
(or (ldb/page? block)
(ldb/object? block))
;; Don't allow :default property value objects to reference their
;; parent block as they are dependent on their block for display
;; and look weirdly recursive - https://github.com/logseq/db-test/issues/36
(not (:logseq.property/created-from-property block))))
(defonce ^:private non-ref-properties
(set/union private-built-in-props
#{:logseq.property/query :logseq.property.publish/published-url :logseq.property/exclude-from-graph-view}))
(defn db-rebuild-block-refs
"Rebuild block refs for DB graphs, should returns ids"
[db block & {:keys [page-or-object?-memoized]}]
(let [block-db-id (:db/id block)
;; explicit lookup in order to be nbb compatible
properties (->>
(entity-plus/lookup-kv-then-entity (d/entity db block-db-id) :block/properties)
(remove (fn [[k _v]]
(non-ref-properties k)))
(into {}))
property-key-refs (->> (keys properties)
(keep (fn [ident]
(:db/id (d/entity db ident)))))
page-or-object? (or page-or-object?-memoized page-or-object?-helper)
property-value-refs (->> properties
(mapcat (fn [[property v]]
(cond
(and (coll? v) (every? page-or-object? v))
(map :db/id v)
(page-or-object? v)
[(:db/id v)]
:else
(build-journal-refs-for-datetime-properties db property v)))))
property-refs (concat property-key-refs property-value-refs)
content-refs (block-content-refs db block)]
(->> (concat (map ref->eid (:block/tags block))
(when-let [id (:db/id (:block/link block))]
[id])
property-refs content-refs)
distinct
;; Remove self-ref to avoid recursive bugs
(remove #(or (identical? block-db-id %)
(identical? block-db-id (:db/id (d/entity db %)))))
;; Remove alias ref to avoid recursive display bugs
(remove #(some (fn [alias-id] (identical? alias-id %)) (map :db/id (:block/alias block))))
(remove nil?))))
(defn- rebuild-block-refs-tx
[{:keys [db-after]} blocks]
(mapcat (fn [block]
(when (d/entity db-after (:db/id block))
(let [refs (db-rebuild-block-refs db-after block)]
(when (seq refs)
[[:db/retract (:db/id block) :block/refs]
{:db/id (:db/id block)
:block/refs refs}]))))
blocks))
(defn transact-new-db-graph-refs
"Transacts :block/refs for a new or imported DB graph"
[conn tx-report]
(let [{:keys [blocks]} (ds-report/get-blocks-and-pages tx-report)
refs-tx-report (when-let [refs-tx (and (seq blocks) (rebuild-block-refs-tx tx-report blocks))]
(ldb/transact! conn refs-tx (-> (:tx-meta tx-report)
(assoc :transact-new-graph-refs? true))))]
refs-tx-report))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
(ns logseq.outliner.recycle
"Recycle-based soft delete helpers for DB graphs"
(:require [datascript.core :as d]
[logseq.common.util :as common-util]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.common.delete-blocks :as delete-blocks]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.initial-data :as common-initial-data]
[logseq.db.common.order :as db-order]))
(def ^:private recycle-page-title "Recycle")
(def ^:private retention-ms (* 30 24 3600 1000))
(def gc-interval-ms (* 24 3600 1000))
(defn- recycled?
[entity]
(some? (:logseq.property/deleted-at entity)))
(defn- build-recycle-page-tx
[db-id]
(let [now (common-util/time-ms)]
{:db/id db-id
:block/uuid (common-uuid/gen-uuid :builtin-block-uuid recycle-page-title)
:block/name (common-util/page-name-sanity-lc recycle-page-title)
:block/title recycle-page-title
:block/created-at now
:block/updated-at now
:logseq.property/hide? true
:logseq.property/built-in? true}))
(defn- recycle-page
[db]
(ldb/get-built-in-page db recycle-page-title))
(defn- ensure-recycle-page
[db]
(if-let [page (recycle-page db)]
{:page page
:page-id (:db/id page)
:tx-data []}
{:page nil
:page-id "recycle-page"
:tx-data [(build-recycle-page-tx "recycle-page")]}))
(defn- next-child-order
[parent]
(let [last-child (last (ldb/sort-by-order (:block/_parent parent)))]
(db-order/gen-key (:block/order last-child) nil)))
(defn- maybe-assoc-ref
[m k entity]
(if (and entity (:db/id entity))
(assoc m k (:db/id entity))
m))
(defn- maybe-assoc
[m k v]
(if (some? v)
(assoc m k v)
m))
(defn- resolve-entity
[db value]
(cond
(and value (:db/id value)) value
(int? value) (d/entity db value)
(vector? value) (d/entity db value)
:else nil))
(defn- block-subtree
[db block]
(let [ids (cons (:db/id block)
(common-initial-data/get-block-full-children-ids db (:db/id block)))]
(keep #(d/entity db %) ids)))
(defn- block-children
[block]
(entity-plus/lookup-kv-then-entity block :block/_raw-parent))
(defn- page-descendants
[page]
(loop [pages [page]
result []]
(if-let [page' (first pages)]
(let [children (->> (block-children page')
(filter ldb/page?)
ldb/sort-by-order)]
(recur (concat (rest pages) children)
(conj result page')))
result)))
(defn- page-block-subtree-ids
[db page]
(let [root-blocks (->> (concat (:block/_page page)
(remove ldb/page? (block-children page)))
(common-util/distinct-by :db/id)
ldb/sort-by-order)]
(->> root-blocks
(mapcat (fn [block]
(map :db/id (block-subtree db block)))))))
(defn- page-tree-ids
[db page]
(->> (page-descendants page)
(mapcat (fn [page']
(cons (:db/id page')
(page-block-subtree-ids db page'))))
distinct))
(defn- deleted-by-id
[db deleted-by-uuid]
(some-> deleted-by-uuid
(#(d/entity db [:block/uuid %]))
:db/id))
(defn- with-delete-cleanup-tx
[db tx-data]
(distinct (concat tx-data (delete-blocks/update-refs-history db tx-data {}))))
(defn recycle-blocks-tx-data
[db blocks {:keys [deleted-by-uuid now-ms]}]
(let [{:keys [page page-id tx-data]} (ensure-recycle-page db)
deleted-by-id' (deleted-by-id db deleted-by-uuid)
now-ms (or now-ms (common-util/time-ms))
[recycle-tx _previous-order]
(reduce
(fn [[txs previous-order] block]
(let [subtree (block-subtree db block)
order (db-order/gen-key previous-order nil)
root-tx (cond-> {:db/id (:db/id block)
:block/parent page-id
:block/page page-id
:block/order order
:logseq.property/deleted-at now-ms}
true
(maybe-assoc-ref :logseq.property/deleted-by-ref (d/entity db deleted-by-id'))
true
(maybe-assoc-ref :logseq.property.recycle/original-parent (:block/parent block))
true
(maybe-assoc-ref :logseq.property.recycle/original-page (:block/page block))
true
(maybe-assoc :logseq.property.recycle/original-order (:block/order block)))
subtree-page-tx (map (fn [node]
{:db/id (:db/id node)
:block/page page-id})
subtree)]
[(into txs (cons root-tx (rest subtree-page-tx))) order]))
[[] (some->> page :block/_parent ldb/sort-by-order last :block/order)]
blocks)]
(concat tx-data recycle-tx)))
(defn recycle-page-tx-data
[db page {:keys [deleted-by-uuid now-ms]}]
(let [{recycle-page-id :page-id
recycle-page-init-tx-data :tx-data
recycle-page-existing :page} (ensure-recycle-page db)
deleted-by-db-id (deleted-by-id db deleted-by-uuid)
now-ms (or now-ms (common-util/time-ms))]
(concat recycle-page-init-tx-data
[(cond-> {:db/id (:db/id page)
:block/parent recycle-page-id
:block/order (if recycle-page-existing
(next-child-order recycle-page-existing)
(db-order/gen-key nil nil))
:logseq.property/deleted-at now-ms}
true
(maybe-assoc-ref :logseq.property/deleted-by-ref (d/entity db deleted-by-db-id))
true
(maybe-assoc-ref :logseq.property.recycle/original-parent (:block/parent page))
true
(maybe-assoc-ref :logseq.property.recycle/original-page page)
true
(maybe-assoc :logseq.property.recycle/original-order (:block/order page)))])))
(defn- restore-order
[target-parent]
(next-child-order target-parent))
(defn- restore-target
[db root]
(let [original-parent (resolve-entity db (:logseq.property.recycle/original-parent root))
original-page (resolve-entity db (:logseq.property.recycle/original-page root))
parent-valid? (and original-parent
(not (recycled? original-parent))
(d/entity db (:db/id original-parent)))]
(cond
(ldb/page? root)
{:parent (when parent-valid? original-parent)
:page root
:order (or (:logseq.property.recycle/original-order root)
(when parent-valid? (restore-order original-parent)))}
parent-valid?
{:parent original-parent
:page original-page
:order (or (:logseq.property.recycle/original-order root)
(restore-order original-parent))}
(and original-page
(d/entity db (:db/id original-page))
(not (recycled? original-page)))
{:parent original-page
:page original-page
:order (restore-order original-page)}
:else
nil)))
(defn ^:api restore-tx-data
[db root]
(when-let [{:keys [parent page order]} (restore-target db root)]
(let [subtree (when-not (ldb/page? root)
(block-subtree db root))
clear-structure [[:db/retract (:db/id root) :block/parent]
[:db/retract (:db/id root) :block/order]
(when-not (ldb/page? root)
[:db/retract (:db/id root) :block/page])]
clear-meta [[:db/retract (:db/id root) :logseq.property/deleted-at]
[:db/retract (:db/id root) :logseq.property/deleted-by-ref]
[:db/retract (:db/id root) :logseq.property.recycle/original-parent]
[:db/retract (:db/id root) :logseq.property.recycle/original-page]
[:db/retract (:db/id root) :logseq.property.recycle/original-order]]
root-tx (cond-> {:db/id (:db/id root)}
parent
(assoc :block/parent (:db/id parent))
order
(assoc :block/order order)
(not (ldb/page? root))
(assoc :block/page (:db/id page)))
subtree-page-tx (when (seq subtree)
(map (fn [node]
{:db/id (:db/id node)
:block/page (:db/id page)})
subtree))]
(concat clear-structure [root-tx] subtree-page-tx (remove nil? clear-meta)))))
(defn ^:api restore!
[conn root-uuid]
(when-let [root (d/entity @conn [:block/uuid root-uuid])]
(when-let [tx-data (seq (restore-tx-data @conn root))]
(ldb/transact! conn tx-data {:outliner-op :restore-recycled})
true)))
(defn ^:api permanently-delete-tx-data
[db root]
(when (and root (recycled? root))
(some->> (if (ldb/page? root)
(keep (fn [id]
(some-> (d/entity db id) :block/uuid))
(page-tree-ids db root))
(keep :block/uuid (block-subtree db root)))
(map (fn [block-uuid]
[:db/retractEntity [:block/uuid block-uuid]]))
distinct
seq
(with-delete-cleanup-tx db)
seq)))
(defn ^:api permanently-delete!
[conn root-uuid]
(when-let [root (d/entity @conn [:block/uuid root-uuid])]
(when-let [tx-data (permanently-delete-tx-data @conn root)]
(ldb/transact! conn tx-data {:outliner-op :recycle-delete-permanently})
true)))
(defn- gc-tx-data
[db {:keys [now-ms] :or {now-ms (common-util/time-ms)}}]
(let [cutoff (- now-ms retention-ms)]
(->>
(d/q '[:find [?e ...]
:in $ ?cutoff
:where
[?e :logseq.property/deleted-at ?deleted-at]
[(<= ?deleted-at ?cutoff)]]
db cutoff)
(map #(d/entity db %))
(filter recycled?)
(mapcat (fn [entity]
(if (ldb/page? entity)
(map (fn [id] [:db/retractEntity id]) (page-tree-ids db entity))
(map (fn [node] [:db/retractEntity (:db/id node)]) (block-subtree db entity)))))
distinct
seq
(with-delete-cleanup-tx db))))
(defn ^:api gc!
[conn opts]
(when-let [tx-data (seq (gc-tx-data @conn opts))]
(ldb/transact! conn tx-data {:outliner-op :recycle-gc
:persist-op? false})
true))

View File

@@ -0,0 +1,170 @@
(ns logseq.outliner.template
"Template with variables"
(:require [clojure.string :as string]
[datascript.core :as d]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]))
(def ^:private template-re #"<%([^%].*?)%>")
(defn- current-time
[]
(let [d (js/Date.)
locale (some-> js/globalThis (aget "navigator") (aget "language"))]
(.toLocaleTimeString d locale (clj->js {:hour "2-digit"
:minute "2-digit"
:hourCycle "h23"}))))
(defn- date-with-day-offset
[offset-days]
(let [d (js/Date.)]
(.setHours d 0 0 0 0)
(.setDate d (+ (.getDate d) offset-days))
d))
(defn- date->journal-day
[^js/Date date]
(let [year (.getFullYear date)
month (inc (.getMonth date))
day (.getDate date)
month' (if (< month 10) (str "0" month) (str month))
day' (if (< day 10) (str "0" day) (str day))]
(js/parseInt (str year month' day') 10)))
(def ^:private journal-variable-offsets
{"today" 0
"yesterday" -1
"tomorrow" 1})
(defn- journal-title
[db offset-days]
(let [journal-day (date->journal-day (date-with-day-offset offset-days))]
(or (d/q '[:find ?title .
:in $ ?journal-day
:where
[?p :block/journal-day ?journal-day]
[?p :block/title ?title]]
db journal-day)
(str journal-day))))
(defn- journal-page
[db offset-days]
(let [journal-day (date->journal-day (date-with-day-offset offset-days))]
(when-let [page-id (d/q '[:find ?p .
:in $ ?journal-day
:where
[?p :block/journal-day ?journal-day]]
db journal-day)]
(d/entity db page-id))))
(defn- journal-page-or-title
[db offset-days]
(or (journal-page db offset-days)
(journal-title db offset-days)))
(defn- target-page
[target]
(cond
(ldb/page? target)
target
:else
(:block/page target)))
(defn- page-ref-for
[page-or-title]
(page-ref/->page-ref
(if-let [page-uuid (:block/uuid page-or-title)]
page-uuid
page-or-title)))
(defn- dynamic-template-matches
[content]
(when (string? content)
(map (comp string/lower-case string/trim second)
(re-seq template-re content))))
(defn- block-template-contents
[block]
(concat
[(:block/title block) (:block/raw-title block)]
(when (map? (:block/properties-text-values block))
(vals (:block/properties-text-values block)))))
(defn dynamic-template-journal-days
[blocks]
(->> blocks
(mapcat block-template-contents)
(mapcat dynamic-template-matches)
(keep journal-variable-offsets)
(map #(date->journal-day (date-with-day-offset %)))
distinct
vec))
(defn- variable-rules
[db target]
(let [today (journal-page-or-title db 0)
current-page (or (target-page target) today)]
{"today" (page-ref-for today)
"yesterday" (page-ref-for (journal-page-or-title db -1))
"tomorrow" (page-ref-for (journal-page-or-title db 1))
"time" (current-time)
"current page" (page-ref-for current-page)}))
(defn- resolve-string
[content rules]
(string/replace content template-re
(fn [[_ match]]
(let [match' (string/trim match)
lowered (string/lower-case match')]
(cond
(string/blank? match')
""
(contains? rules lowered)
(or (get rules lowered) "")
:else
match')))))
(defn- normalize-block
[block]
(cond-> (into {} block)
(:db/id block)
(assoc :db/id (:db/id block))))
(defn- resolve-field
[value rules]
(if (string? value)
(resolve-string value rules)
value))
(defn- resolve-properties-text-values
[value rules]
(if (map? value)
(reduce-kv (fn [m k v]
(assoc m k (resolve-field v rules)))
{}
value)
value))
(defn- resolve-block
[block rules]
(cond-> block
(contains? block :block/title)
(update :block/title resolve-field rules)
(contains? block :block/raw-title)
(update :block/raw-title resolve-field rules)
(contains? block :block/properties-text-values)
(update :block/properties-text-values resolve-properties-text-values rules)))
(defn resolve-dynamic-template-blocks
[db target blocks]
(let [rules (variable-rules db target)]
(mapv (fn [block]
(-> block
normalize-block
(resolve-block rules)))
blocks)))

View File

@@ -0,0 +1,39 @@
(ns logseq.outliner.transaction
"Provides a wrapper around logseq.outliner.datascript/transact! using
transient state from logseq.outliner.core"
#?(:cljs (:require-macros [logseq.outliner.transaction])))
(defmacro ^:api with-batch-tx
[conn opts & body]
(let [conn-sym (gensym "conn__")]
`(logseq.db/batch-transact-with-temp-conn!
~conn
(dissoc ~opts :additional-tx :transact-opts :current-block)
(fn [~conn-sym]
(let [~conn ~conn-sym]
~@body
(when (seq (:additional-tx ~opts))
(logseq.db/transact! ~conn-sym (:additional-tx ~opts) {})))))))
(defmacro ^:api transact!
"Batch all the transactions in `body` to a single transaction.
Currently there are no options, it'll execute body and collect all transaction data generated by body.
If no transactions are included in `body`, it does not save a transaction.
`Args`:
`opts`: Every key is optional, opts except `additional-tx` will be transacted as `tx-meta`.
{:transact-opts {:conn conn} \"Datascript conn\"
:additional-tx \"Additional tx data that can be bundled together
with the body in this macro.\"
:persist-op? \"Boolean, store ops into db (sqlite)\"}
`Example`:
(transact! {:conn db-conn}
(insert-blocks! ...)
;; do something
(move-blocks! ...)
(delete-blocks! ...))"
[opts & body]
`(let [~'conn (:conn (:transact-opts ~opts))]
(logseq.outliner.transaction/with-batch-tx
~'conn
~opts
~@body)))

View File

@@ -0,0 +1,128 @@
(ns logseq.outliner.tree
"Provides tree fns and INode protocol"
(:require [datascript.core :as d]
[datascript.impl.entity :as de]
[logseq.db :as ldb]))
(defprotocol INode
(-save [this *txs-state conn opts])
(-del [this *txs-state db]))
(defn ^:api blocks->vec-tree-data
[blocks root {:keys [include-root? keep-block-tx-id?]}]
(if-not (:db/id root)
blocks
(let [root-id (:db/id root)
parent-blocks (group-by #(get-in % [:block/parent :db/id]) blocks)
sort-fn (fn [parent]
(when-let [children (get parent-blocks parent)]
(ldb/sort-by-order children)))
block-children (fn block-children [parent level]
(mapv (fn [m]
(let [id (:db/id m)
children (block-children id (inc level))]
(cond-> (assoc m
:block/level level
:block/children children
:block/parent {:db/id parent})
(not keep-block-tx-id?)
(dissoc :block/tx-id))))
(sort-fn parent)))
children (block-children root-id 1)]
(if include-root?
[(cond-> (assoc root :block/children children)
(not keep-block-tx-id?)
(dissoc :block/tx-id))]
children))))
(defn- get-root-and-page
[db root-id]
(cond
(uuid? root-id)
(let [e (d/entity db [:block/uuid root-id])]
(if (ldb/page? e) [true e] [false e]))
(number? root-id)
(let [e (d/entity db root-id)]
(if (ldb/page? e) [true e] [false e]))
(string? root-id)
(if-let [id (parse-uuid root-id)]
[false (d/entity db [:block/uuid id])]
[true (ldb/get-page db root-id)])
:else
[false root-id]))
;; TODO: entity can already be used as a tree
(defn blocks->vec-tree
"`blocks` need to be in the same page."
[db blocks root-id & {:as option}]
(let [blocks (map (fn [b] (if (de/entity? b)
(assoc (into {} b) :db/id (:db/id b))
b)) blocks)
[page? root] (get-root-and-page db root-id)]
(if-not root ; custom query
blocks
(let [include-root? (or (not page?) (:link option))
root (if include-root?
(some #(when (= (:db/id %) (:db/id root)) %) blocks)
root)]
(blocks->vec-tree-data blocks root {:include-root? include-root?})))))
(defn- tree [parent->children root default-level]
(let [root-id (:db/id root)
nodes (fn nodes [parent-id level]
(mapv (fn [b]
(let [b' (assoc b :block/level (inc level))
children (nodes (:db/id b) (inc level))]
(if (seq children)
(assoc b' :block/children children)
b')))
(let [parent {:db/id parent-id}]
(-> (get parent->children parent)
(ldb/sort-by-order)))))
children (nodes root-id 1)
root' (assoc root :block/level (or default-level 1))]
(if (seq children)
(assoc root' :block/children children)
root')))
(defn ^:api block-entity->map
[e]
(if (de/entity? e)
(cond-> {:db/id (:db/id e)
:block/uuid (:block/uuid e)
:block/parent {:db/id (:db/id (:block/parent e))}
:block/page (:block/page e)}
(:block/refs e)
(assoc :block/refs (:block/refs e))
(:block/children e)
(assoc :block/children (:block/children e)))
(cond-> e
(:db/id (:block/parent e))
(assoc :block/parent {:db/id (:db/id (:block/parent e))}))))
(defn ^:api filter-top-level-blocks
[blocks]
(let [id->blocks (zipmap (map :db/id blocks) blocks)]
(filter #(nil?
(id->blocks
(:db/id (:block/parent (id->blocks (:db/id %)))))) blocks)))
(defn non-consecutive-blocks->vec-tree
"`blocks` need to be in the same page."
([blocks]
(non-consecutive-blocks->vec-tree blocks 1))
([blocks default-level]
(let [blocks (map block-entity->map blocks)
top-level-blocks (filter-top-level-blocks blocks)
top-level-blocks' (ldb/sort-by-order top-level-blocks)
parent->children (group-by :block/parent blocks)]
(map #(tree parent->children % (or default-level 1)) top-level-blocks'))))
(defn get-sorted-block-and-children
[db db-id & {:as opts}]
(when db-id
(when-let [root-block (d/entity db db-id)]
(ldb/get-block-and-children db (:block/uuid root-block) opts))))

View File

@@ -0,0 +1,8 @@
(ns logseq.outliner.tx-meta
"Helpers for normalizing tx metadata with explicit outliner op entries.")
(defn ensure-outliner-ops
[tx-meta entry]
(cond-> tx-meta
(and entry (nil? (:outliner-ops tx-meta)))
(assoc :outliner-ops [entry])))

View File

@@ -0,0 +1,378 @@
(ns logseq.outliner.validate
"Reusable DB graph validations for outliner level and above. Most validations
throw errors so the user action stops immediately to display a notification"
(:require [clojure.set :as set]
[clojure.string :as string]
[datascript.core :as d]
[logseq.common.date :as common-date]
[logseq.common.util.namespace :as ns-util]
[logseq.db :as ldb]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.malli-schema :as db-malli-schema]
[logseq.db.frontend.property :as db-property]))
(defn ^:api validate-page-title-no-hashtag
"Validates a page title doesn't include hashtag character"
[page-title meta-m]
(when (string/includes? page-title "#")
(throw (ex-info "Page name can't include \"#\"."
(merge meta-m
{:type :notification
:payload {:message "Page name can't include \"#\"."
:i18n-key :page.validation/name-no-hash
:type :warning}})))))
(defn ^:api validate-page-title-characters
"Validates characters that must not be in a page title"
[page-title meta-m]
(validate-page-title-no-hashtag page-title meta-m)
(when (and (string/includes? page-title ns-util/parent-char)
(not (common-date/normalize-date page-title nil)))
(throw (ex-info "Page name can't include \"/\"."
(merge meta-m
{:type :notification
:payload {:message "Page name can't include \"/\"."
:i18n-key :page.validation/name-no-slash
:type :warning}})))))
(defn ^:api validate-page-title
[page-title meta-m]
(when (string/blank? page-title)
(throw (ex-info "Page name can't be blank"
(merge meta-m
{:type :notification
:payload {:message "Page name can't be blank."
:i18n-key :page.validation/name-blank
:type :warning}})))))
(defn- find-other-ids-with-title-and-tags
"Query that finds other ids given the id to ignore, title to look up and tags to consider"
[entity]
(cond
(ldb/property? entity)
;; Property names are unique in that they can
;; have the same names as built-in property names
'[:find [?b ...]
:in $ ?eid ?title [?tag-id ...]
:where
[?b :block/title ?title]
[?b :block/tags ?tag-id]
[(missing? $ ?b :logseq.property/built-in?)]
[(not= ?b ?eid)]]
(:block/parent entity)
'[:find [?b ...]
:in $ ?eid ?title [?tag-id ...]
:where
[?b :block/title ?title]
[?b :block/tags ?tag-id]
[(not= ?b ?eid)]
;; same parent
[?b :block/parent ?bp]
[?eid :block/parent ?ep]
[(= ?bp ?ep)]]
:else
'[:find [?b ...]
:in $ ?eid ?title [?tag-id ...]
:where
[?b :block/title ?title]
[?b :block/tags ?tag-id]
[(not= ?b ?eid)]]))
(defn- validate-unique-for-page
[db new-title {:block/keys [tags] :as entity}]
(when (seq tags)
(when-let [another-id (first
(d/q (find-other-ids-with-title-and-tags entity)
db
(:db/id entity)
new-title
(map :db/id tags)))]
(let [another (d/entity db another-id)
this-tags (set (map :db/ident tags))
another-tags (set (map :db/ident (:block/tags another)))
common-tag-ids (set/intersection this-tags another-tags)]
(when-not (and (= common-tag-ids #{:logseq.class/Page})
(> (count this-tags) 1)
(> (count another-tags) 1))
(cond
(ldb/property? entity)
(throw (ex-info "Duplicate property"
{:type :notification
:payload {:message (str "Another property named " (pr-str new-title) " already exists.")
:i18n-key :property.validation/duplicate
:i18n-args [new-title]
:type :warning}}))
(ldb/class? entity)
(throw (ex-info "Duplicate class"
{:type :notification
:payload {:message (str "Another tag named " (pr-str new-title) " already exists.")
:i18n-key :class.validation/duplicate
:i18n-args [new-title]
:type :warning}}))
:else
(throw (ex-info "Duplicate page"
{:type :notification
:payload {:message (str "Another page named " (pr-str new-title) " already exists for tags: "
(string/join ", "
(map (fn [id] (str "#" (:block/title (d/entity db id)))) common-tag-ids)))
:i18n-key :page.validation/duplicate
:i18n-args [new-title
(string/join ", "
(map (fn [id] (str "#" (:block/title (d/entity db id)))) common-tag-ids))]
:type :warning}}))))))))
(defn ^:api validate-unique-by-name-and-tags
"Validates uniqueness of nodes for the following cases:
- Page names are unique for a tag e.g. their can be Apple #Company and Apple #Fruit
- Property names are unique with user properties being allowed to have the same name as built-in ones
- Class names are unique regardless of their extends or if they're built-in"
[db new-title entity]
(when (entity-util/page? entity)
(validate-unique-for-page db new-title entity)))
(defn ^:api validate-disallow-page-with-journal-name
"Validates a non-journal page renamed to journal format"
[new-title entity]
(when (and (entity-util/page? entity) (not (entity-util/journal? entity))
(common-date/normalize-date new-title nil))
(throw (ex-info "Page can't be renamed to a journal"
{:type :notification
:payload {:message "This page can't be changed to a journal page"
:i18n-key :journal/page-cant-convert-warning
:type :warning}}))))
(defn validate-block-title
"Validates a block title when it has changed for a entity-util/page? or tagged node"
[db new-title existing-block-entity]
(validate-unique-by-name-and-tags db new-title existing-block-entity)
(validate-disallow-page-with-journal-name new-title existing-block-entity))
(defn validate-property-title
"Validates a property's title when it has changed"
([new-title] (validate-property-title new-title {}))
([new-title meta-m]
(when-not (db-property/valid-property-name? new-title)
(throw (ex-info "Property name is invalid"
(merge meta-m
{:type :notification
:payload {:message "This is an invalid property name. A property name cannot start with page reference characters '#' or '[['."
:i18n-key :property.validation/invalid-name
:type :error}}))))))
(defn validate-editing-built-in-property
"Validates if built-in property entity is editable for the given attributes to be updated"
[entity attribute-map-to-update]
;; Update allowed as needed. Keep this as an allowed list to default to safe editing for built-in entities
(let [allowed-attributes #{:logseq.property/hide-empty-value :logseq.property/description}]
(when-let [disallowed (and (:logseq.property/built-in? entity)
(not-empty (set/difference (set (keys attribute-map-to-update))
allowed-attributes)))]
(throw (ex-info "Given built-in property's attributes are not editable"
(merge
{:type :notification
:payload {:message "Can't change the given attributes for a built-in property"
:type :error}}
{:property (:db/ident entity)
:disallowed-attributes disallowed}))))))
(defn- validate-extends-property-have-correct-type
"Validates whether given parent and children are classes"
[parent-ent child-ents]
(when (or (not (ldb/class? parent-ent))
(not (every? ldb/class? child-ents)))
(throw (ex-info "Can't extend this page since either it is not a tag or is extending from a page that is not a tag"
{:type :notification
:payload {:message "Can't extend this page since either it is not a tag or is extending from a page that is not a tag"
:i18n-key :class.validation/invalid-extends-type
:type :error}
:blocks (map #(select-keys % [:db/id :block/title]) (remove ldb/class? child-ents))}))))
(defn- disallow-built-in-class-extends-change
[_parent-ent child-ents]
(when (some #(get db-class/built-in-classes (:db/ident %)) child-ents)
(throw (ex-info "Can't change the extends of a built-in tag"
{:type :notification
:payload {:message "Can't change the extends of a built-in tag"
:i18n-key :class.validation/built-in-extends-change
:type :error}}))))
(defn- disallow-extends-cycle
[db parent-ent child-ents]
(doseq [child child-ents]
(let [children-ids (set (cons (:db/id child)
(db-class/get-structured-children db (:db/id child))))]
(when (contains? children-ids (:db/id parent-ent))
(throw (ex-info "Extends cycle"
{:type :notification
:payload {:message "Tag extends cycle"
:i18n-key :class.validation/extends-cycle
:type :error
:blocks (map #(select-keys % [:db/id :block/title]) [child])}}))))))
(defn validate-extends-property
[db parent-ent* child-ents & {:keys [built-in?] :or {built-in? true}}]
(let [parent-ent (if (integer? parent-ent*)
(d/entity db parent-ent*)
parent-ent*)]
(when built-in? (disallow-built-in-class-extends-change parent-ent child-ents))
(disallow-extends-cycle db parent-ent child-ents)
(validate-extends-property-have-correct-type parent-ent child-ents)))
(defn- disallow-node-cant-tag-with-built-in-non-tags
[db _block-eids v]
(let [tag-ent (d/entity db v)]
(when (and (:logseq.property/built-in? tag-ent)
(not (ldb/class? tag-ent)))
(throw (ex-info (str "Can't set tag with built-in page that isn't a tag " (pr-str (:block/title tag-ent)))
{:type :notification
:payload {:message (str "Can't set tag with built-in page that isn't a tag " (pr-str (:block/title tag-ent)))
:i18n-key :class.validation/tag-with-non-tag
:i18n-args [(:block/title tag-ent)]
:type :error}
:property-value v})))))
(defn- disallow-node-cant-tag-with-private-tags
[db block-eids v & {:keys [delete?]}]
;; Skip #Page as it is validated by later fns
(when (and (contains? (disj ldb/private-tags :logseq.class/Page) (:db/ident (d/entity db v)))
(not
;; Allow assets to be tagged
(and
(every? (fn [id] (ldb/asset? (d/entity db id))) block-eids)
(= :logseq.class/Asset (:db/ident (d/entity db v))))))
(let [tag-title (:block/title (d/entity db v))]
(throw (ex-info (str (if delete? "Can't remove tag" "Can't set tag")
" with built-in #" tag-title)
{:type :notification
:payload {:message (str (if delete? "Can't remove tag" "Can't set tag")
" with built-in #" tag-title)
:i18n-key (if delete? :class.validation/cant-remove-tag-built-in :class.validation/cant-set-tag-built-in)
:i18n-args [tag-title]
:type :error}
:property-id :block/tags
:property-value v})))))
(defn built-in-entity?
"Returns true when the entity is a built-in. Ideally checking
:logseq.property/built-in? would be enough but not all built-in nodes have
that property. Covers:
- entities marked with :logseq.property/built-in? (built-in pages, classes, properties)
- file entities (logseq/config.edn, custom.css, etc.)
- entities whose :db/ident belongs to an internal namespace (KV entries, empty-placeholder)"
[ent]
(or (:logseq.property/built-in? ent)
(:file/path ent)
(some-> (:db/ident ent) db-malli-schema/internal-ident?)))
(defn- disallow-tagging-a-built-in-entity
[db block-eids & {:keys [delete?]}]
(when-let [built-in-ent (some #(when (built-in-entity? %) %)
(map #(d/entity db %) block-eids))]
(throw (ex-info (str (if delete? "Can't remove tag" "Can't add tag")
" on built-in " (pr-str (:block/title built-in-ent)))
{:type :notification
:payload {:message (str (if delete? "Can't remove tag" "Can't add tag")
" on built-in " (pr-str (:block/title built-in-ent)))
:i18n-key (if delete? :class.validation/cant-remove-tag-on-built-in :class.validation/cant-add-tag-on-built-in)
:i18n-args [(:block/title built-in-ent)]
:type :error}}))))
(defn- disallow-removing-page-tag
"Disallow page->block when
1. this page doesn't have :block/parent
2. its parent is Library
3. it has page child"
[db eids v]
(when (= (:db/ident (d/entity db v)) :logseq.class/Page)
(let [library-page (ldb/get-library-page db)]
(doseq [eid eids]
(let [entity (d/entity db eid)]
(when (ldb/internal-page? entity)
(cond
(not (:block/parent entity))
(throw (ex-info "This page cannot be converted to a block"
{:type :notification
:payload
{:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block")
:type :error
:i18n-key :page.convert/cant-be-block
:i18n-args [(:block/title entity)]
:entity (into {} entity)
:property :block/tags}}))
(= (:db/id library-page) (:db/id (:block/parent entity)))
(throw (ex-info "This page cannot be converted to a block"
{:type :notification
:payload
{:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block, please move it to another page first")
:type :error
:i18n-key :page.convert/cant-be-block-move-first
:i18n-args [(:block/title entity)]
:entity (into {} entity)
:property :block/tags}}))
(some entity-util/page? (:block/_parent entity))
(throw (ex-info "This page cannot be converted to a block"
{:type :notification
:payload
{:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block because it has page children")
:type :error
:i18n-key :page.convert/cant-be-block-has-children
:i18n-args [(:block/title entity)]
:entity (into {} entity)
:property :block/tags}})))))))))
(defn- validate-block-can-tag-with-page-tag
"Validates block can convert to page by adding #Page for allowed scenarios"
[db eids v]
(when (= (:db/ident (d/entity db v)) :logseq.class/Page)
(doseq [eid eids]
(let [block (d/entity db eid)]
(when (:block/parent block)
(validate-page-title (:block/title block) {:node block})
(validate-page-title-characters (:block/title block) {:node block})
;; Only allow block to be page when its parent is a page to guard against invalid pages
;; in property values or pages being created with blocks as namespace parents
(when (or (not (entity-util/page? (:block/parent block)))
(:logseq.property/created-from-property block))
(let [message (if (:logseq.property/created-from-property block)
"Can't convert property value to page."
"Can't convert this block to page since its parent is not a page.")
i18n-key (if (:logseq.property/created-from-property block)
:page.convert/property-value-to-page
:page.convert/block-parent-not-page)]
(throw (ex-info message
{:type :notification
:payload {:message message
:i18n-key i18n-key
:type :error
:block (into {} block)}})))))))))
(defn validate-tags-property
"Validates adding a property value to :block/tags for given blocks"
[db block-eids v]
(disallow-tagging-a-built-in-entity db block-eids)
(disallow-node-cant-tag-with-private-tags db block-eids v)
(validate-block-can-tag-with-page-tag db block-eids v)
(disallow-node-cant-tag-with-built-in-non-tags db block-eids v))
(defn validate-tags-property-deletion
"Validates deleting a property value from :block/tags for given blocks"
[db block-eids v]
(disallow-tagging-a-built-in-entity db block-eids {:delete? true})
(disallow-node-cant-tag-with-private-tags db block-eids v {:delete? true})
(disallow-removing-page-tag db block-eids v))
(defn disallow-editing-private-built-in-nodes
"Disallow editing private :built-in nodes. This explicit validation is needed for contexts
like CLI and API which allow users to edit any built-in entity whereas the app guards this
by not allowing users to navigate to private built-in nodes"
[entities]
(doseq [entity entities]
(when (and (built-in-entity? entity)
;; This also checks private status of non-page ents like ents with :kv/value
(ldb/private-built-in-page? entity))
(throw (ex-info "Built-in private nodes can't be modified"
{:type :notification
:payload {:message "Built-in private nodes can't be modified"
:type :error}})))))

View File

@@ -0,0 +1,327 @@
(ns logseq.outliner.core-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.common.config :as common-config]))
(deftest insert-blocks-does-not-trust-stale-right-order
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}
{:block/title "original right"}]}])
target (db-test/find-block-by-content @conn "target")
stale-right-order (:block/order (db-test/find-block-by-content @conn "original right"))]
(outliner-core/insert-blocks!
conn
[{:block/uuid (random-uuid)
:block/title "concurrent right"}]
target
{:sibling? true
:keep-uuid? true})
(let [target (db-test/find-block-by-content @conn "target")
current-right (ldb/get-right-sibling target)
{:keys [blocks]} (outliner-core/insert-blocks
@conn
[{:block/uuid (random-uuid)
:block/title "inserted"}]
target
{:sibling? true
:keep-uuid? true
:end-order-state [:known stale-right-order]})
inserted (first blocks)]
(is (= "concurrent right" (:block/title current-right)))
(is (neg? (compare (:block/order inserted) (:block/order current-right)))
"A stale renderer boundary must not place the new block after the current right sibling."))))
(deftest insert-blocks-finds-right-order-on-1k-sibling-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks (mapv (fn [idx]
{:block/title (str "block " idx)})
(range 1000))}])
target (db-test/find-block-by-content @conn "block 0")
insert! #(outliner-core/insert-blocks
@conn
[{:block/uuid (random-uuid)
:block/title "inserted"}]
target
{:sibling? true
:keep-uuid? true})
_ (insert!)
started-at (.now js/performance)
{:keys [blocks]} (insert!)
elapsed-ms (- (.now js/performance) started-at)
right (ldb/get-right-sibling target)]
(is (neg? (compare (:block/order (first blocks)) (:block/order right))))
(is (< elapsed-ms 100)
(str "1k sibling insertion order lookup took " elapsed-ms "ms"))))
(deftest blocks-with-level-handles-10k-deep-tree
(let [blocks (mapv (fn [id]
{:db/id id
:block/uuid (str id)
:block/parent {:db/id (dec id)}})
(range 1 10001))
started-at (.now js/performance)
result (outliner-core/blocks-with-level blocks)
elapsed-ms (- (.now js/performance) started-at)]
(is (= 1 (:block/level (first result))))
(is (= 10000 (:block/level (last result))))
(is (< elapsed-ms 250) (str "10k level calculation took " elapsed-ms "ms"))))
(deftest existing-inline-class-uses-its-canonical-uuid
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"} :blocks []}])
class-uuid (random-uuid)
parsed-uuid (random-uuid)
ref {:block/type "page"
:block/name "existing"
:block/title "Existing"
:block/uuid parsed-uuid}
_ (d/transact! conn [{:db/ident :user.class/existing
:block/name "existing"
:block/title "Existing"
:block/uuid class-uuid
:block/tags :logseq.class/Tag}])
{:keys [block page-txs]}
(#'outliner-core/resolve-page-refs
@conn
{:block/title (str "#[[" parsed-uuid "]]")
:block/refs [ref]
:block/tags [ref]})]
(is (empty? page-txs))
(is (= (str "#[[" class-uuid "]]") (:block/title block)))
(is (= class-uuid (-> block :block/refs first :block/uuid)))
(is (= class-uuid (-> block :block/tags first :block/uuid)))))
(deftest test-delete-block-with-default-property
(testing "Delete block with default property hard retracts the block subtree"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "test block"}}]}])
block (db-test/find-block-by-content @conn "b1")]
(outliner-core/delete-blocks! conn [block] {})
(is (nil? (db-test/find-block-by-content @conn "b1"))))))
(deftest test-delete-page-with-outliner-core
(testing "Deleting pages through outliner-core/delete-blocks detaches page position only"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}
{:page {:block/title "page2"}
:blocks [{:block/title "b3"}
{:block/title "b4"}]}])
page2 (ldb/get-page @conn "page2")
_ (d/transact! conn [{:db/id (:db/id page2)
:block/order "a1"
:block/parent (:db/id (ldb/get-page @conn "page1"))}])
b3 (db-test/find-block-by-content @conn "b3")
b4 (db-test/find-block-by-content @conn "b4")]
(outliner-core/delete-blocks! conn [b3 b4 page2] {})
(is (some? (db-test/find-block-by-content @conn "b3")))
(is (some? (db-test/find-block-by-content @conn "b4")))
(let [page2' (ldb/get-page @conn "page2")]
(is (= "page2" (:block/title page2')))
(is (nil? (:block/parent page2')))
(is (nil? (:block/order page2')))))))
(deftest delete-blocks-hard-retracts-subtree
(let [user-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
parent (db-test/find-block-by-content @conn "parent")]
(d/transact! conn [{:block/uuid user-uuid
:block/title "Alice"}])
(outliner-core/delete-blocks! conn [parent] {:deleted-by-uuid user-uuid})
(let [parent' (db-test/find-block-by-content @conn "parent")
child' (db-test/find-block-by-content @conn "child")]
(is (nil? parent'))
(is (nil? child')))))
(deftest delete-blocks-removes-range-comments-when-all-targets-are-deleted
(let [now (js/Date.now)
comments-uuid (random-uuid)
comment-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target 1"}
{:block/title "target 2"}]}])
page (ldb/get-page @conn "page1")
target-1 (db-test/find-block-by-content @conn "target 1")
target-2 (db-test/find-block-by-content @conn "target 2")]
(d/transact! conn [{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"}
{:db/id -1
:block/uuid comments-uuid
:block/title "Comments"
:block/page (:db/id page)
:block/parent (:db/id page)
:block/order "a3"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Comments}
:logseq.property.comments/blocks #{(:db/id target-1)
(:db/id target-2)}}
{:block/uuid comment-uuid
:block/title "comment"
:block/page (:db/id page)
:block/parent -1
:block/order "a0"
:block/created-at now
:block/updated-at now}])
(outliner-core/delete-blocks! conn [target-1 target-2] {})
(is (nil? (d/entity @conn [:block/uuid comments-uuid])))
(is (nil? (d/entity @conn [:block/uuid comment-uuid])))))
(deftest delete-blocks-keeps-range-comments-when-some-targets-remain
(let [now (js/Date.now)
comments-uuid (random-uuid)
comment-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target 1"}
{:block/title "target 2"}]}])
page (ldb/get-page @conn "page1")
target-1 (db-test/find-block-by-content @conn "target 1")
target-2 (db-test/find-block-by-content @conn "target 2")]
(d/transact! conn [{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"}
{:db/id -1
:block/uuid comments-uuid
:block/title "Comments"
:block/page (:db/id page)
:block/parent (:db/id page)
:block/order "a3"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Comments}
:logseq.property.comments/blocks #{(:db/id target-1)
(:db/id target-2)}}
{:block/uuid comment-uuid
:block/title "comment"
:block/page (:db/id page)
:block/parent -1
:block/order "a0"
:block/created-at now
:block/updated-at now}])
(outliner-core/delete-blocks! conn [target-1] {})
(is (some? (d/entity @conn [:block/uuid comments-uuid])))
(is (some? (d/entity @conn [:block/uuid comment-uuid])))
(is (some? (d/entity @conn (:db/id target-2))))))
(deftest delete-blocks-rejects-built-in-entities
(let [conn (db-test/create-conn)]
(testing "built-in page is rejected"
(let [recycle-page (ldb/get-page @conn common-config/recycle-page-name)]
(is (true? (:logseq.property/built-in? recycle-page)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [recycle-page] {}))))))
(testing "built-in idents that are not a class or property like empty-placeholder are rejected"
(let [placeholder (d/entity @conn :logseq.property/empty-placeholder)]
(is (some? (:block/uuid placeholder)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [placeholder] {}))))))
(testing "file entity is rejected"
(let [file (->> (d/datoms @conn :avet :file/path)
first
:e
(d/entity @conn))]
(is (some? (:file/path file)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [file] {}))))))
(testing "KV entity is rejected"
(let [kv (d/entity @conn :logseq.kv/db-type)]
(is (some? (:db/id kv)))
(is (thrown-with-msg? js/Error #"Built-in nodes can't be deleted"
(db-test/silence-stderr (outliner-core/delete-blocks! conn [kv] {}))))))))
(deftest save-block-rejects-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-core/save-block! conn {:db/id (:db/id placeholder) :block/title "hacked"}))))))
(deftest move-blocks-rejects-built-in-entity
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}])
placeholder (d/entity @conn :logseq.property/description)
target (db-test/find-block-by-content @conn "target")]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-core/move-blocks! conn [placeholder] target {:sibling? true}))))))
(deftest move-blocks-protects-comment-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}
{:block/title "tagged comment"}
{:block/title "normal parent"
:build/children [{:block/title "ordinary"}]}
{:block/title "Comments"}]}])
page (ldb/get-page @conn "page1")
target (db-test/find-block-by-content @conn "target")
tagged-comment (db-test/find-block-by-content @conn "tagged comment")
ordinary (db-test/find-block-by-content @conn "ordinary")
comments-area (db-test/find-block-by-content @conn "Comments")
original-comment-parent-id (:db/id (:block/parent tagged-comment))]
(d/transact! conn [{:db/id -1
:db/ident :logseq.class/Tag
:block/uuid (random-uuid)
:block/title "Tag"
:block/tags #{-1}}
{:db/ident :logseq.class/Comments
:block/uuid (random-uuid)
:block/title "Comments"
:block/tags #{-1}}
{:db/ident :logseq.class/Comment
:block/uuid (random-uuid)
:block/title "Comment"
:block/tags #{-1}}
[:db/add (:db/id comments-area) :block/tags :logseq.class/Comments]
[:db/add (:db/id tagged-comment) :block/tags :logseq.class/Comment]])
(testing "#Comment blocks cannot be moved after creation"
(outliner-core/move-blocks! conn [tagged-comment] target {:sibling? false})
(is (= original-comment-parent-id
(:db/id (:block/parent (d/entity @conn (:db/id tagged-comment)))))))
(testing "#Comments blocks cannot be moved as children"
(outliner-core/move-blocks! conn [comments-area] target {:sibling? false})
(let [comments-area' (d/entity @conn (:db/id comments-area))]
(is (= (:db/id page)
(:db/id (:block/parent comments-area'))))))
(testing "#Comments blocks can be moved as siblings"
(outliner-core/move-blocks! conn [comments-area] target {:sibling? true})
(let [page' (d/entity @conn (:db/id page))
children (->> (:block/_parent page')
ldb/sort-by-order
(mapv :block/title))]
(is (= ["target" "Comments" "tagged comment" "normal parent"] children))))
(testing "ordinary blocks can be moved as siblings of #Comments blocks"
(outliner-core/move-blocks! conn [ordinary] comments-area {:sibling? true})
(let [ordinary' (d/entity @conn (:db/id ordinary))]
(is (= (:db/id page)
(:db/id (:block/parent ordinary'))))))
(testing "ordinary blocks cannot be moved as children of #Comments blocks"
(outliner-core/move-blocks! conn [ordinary] comments-area {:sibling? false})
(is (= (:db/id page)
(:db/id (:block/parent (d/entity @conn (:db/id ordinary)))))))
(testing "ordinary blocks cannot be moved to #Comment blocks"
(outliner-core/move-blocks! conn [ordinary] tagged-comment {:sibling? false})
(is (= (:db/id page)
(:db/id (:block/parent (d/entity @conn (:db/id ordinary)))))))))

View File

@@ -0,0 +1,530 @@
(ns logseq.outliner.op-construct-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.frontend.property :as db-property]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.op.construct :as op-construct]))
(defn- run-direct-outdent
[conn block]
(let [{:keys [tx-data]}
(#'outliner-core/indent-outdent-blocks
conn [block] false
:parent-original nil
:logical-outdenting? nil)
tx-report (d/with @conn tx-data {})]
{:tx-data (:tx-data tx-report)
:db-after (:db-after tx-report)}))
(deftest derive-history-outliner-ops-canonicalizes-create-page-and-builds-delete-inverse-test
(testing "create-page forward op keeps created uuid and reverse op deletes that page"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "Created Page" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
tx-meta {:outliner-op :create-page
:outliner-ops [[:create-page ["Created Page"
{:redirect? false
:split-namespace? true
:tags ()}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= :create-page (ffirst forward-outliner-ops)))
(is (= page-uuid (get-in forward-outliner-ops [0 1 1 :uuid])))
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-handles-replace-empty-target-insert-inverse-test
(testing "replace-empty-target insert keeps source uuid and inverse deletes target placeholder"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title ""}]}]})
empty-target (db-test/find-block-by-content @conn "")
parent-uuid (random-uuid)
child-uuid (random-uuid)
tx-meta {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid parent-uuid
:block/title "paste parent"}
{:block/uuid child-uuid
:block/title "paste child"
:block/parent [:block/uuid parent-uuid]}]
(:db/id empty-target)
{:sibling? true
:replace-empty-target? true
:outliner-op :paste}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= parent-uuid
(get-in forward-outliner-ops [0 1 0 0 :block/uuid])))
(is (= true (get-in forward-outliner-ops [0 1 2 :keep-uuid?])))
(is (some #(and (= :delete-blocks (first %))
(= [(:block/uuid empty-target)]
(vec (get-in % [1 0]))))
(remove nil? inverse-outliner-ops)))
(is (not-any? #(= :save-block (first %))
(remove nil? inverse-outliner-ops))))))
(deftest derive-history-outliner-ops-builds-upsert-property-inverse-delete-page-test
(testing "upsert-property with qualified keyword builds delete-page inverse"
(let [conn (db-test/create-conn-with-blocks {:pages-and-blocks []})
property-id :user.property/custom-prop
tx-meta {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-id
{:logseq.property/type :default}
{:property-name "custom-prop"}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-id)]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-upsert-property-update-builds-schema-restore-inverse-test
(testing "upsert-property on existing property builds inverse upsert-property restore op"
(let [conn (db-test/create-conn-with-blocks
{:properties {:p-many {:logseq.property/type :default}}
:pages-and-blocks []})
property-id :user.property/p-many
_ (d/transact! conn [[:db/add property-id :logseq.property/classes :logseq.class/Root]])
before-property (d/entity @conn property-id)
expected-schema (-> (db-property/get-property-schema (into {} before-property))
(update :logseq.property/classes
(fn [classes]
(some->> classes
(map (fn [class]
(if-let [class-uuid (:block/uuid class)]
[:block/uuid class-uuid]
(:db/ident class))))
set))))
tx-meta {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-id
{:logseq.property/type :node
:db/cardinality :many}
{}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:upsert-property [property-id expected-schema {:property-name "p-many"}]]]
inverse-outliner-ops))
(is (every? (fn [class-ref]
(or (keyword? class-ref)
(and (vector? class-ref)
(= :block/uuid (first class-ref))
(uuid? (second class-ref)))))
(get-in inverse-outliner-ops [0 1 1 :logseq.property/classes]))))))
(deftest derive-history-outliner-ops-delete-blocks-inverse-avoids-self-target-test
(testing "delete-blocks inverse falls back to parent target when left sibling resolves to self"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-id (:db/id child)
child-uuid (:block/uuid child)
parent-uuid (some-> child :block/parent :block/uuid)
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[child-id] {}]]]}]
;; Simulate stale sibling lookup returning the same entity as the deleted root.
(with-redefs [ldb/get-left-sibling (fn [_] child)]
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)
insert-op (first inverse-outliner-ops)]
(is (= :insert-blocks (first insert-op)))
(is (= parent-uuid
(get-in insert-op [1 1])))
(is (= false (get-in insert-op [1 2 :sibling?])))
(is (not= child-uuid
(get-in insert-op [1 1]))))))))
(deftest compound-history-inverses-run-in-reverse-dependency-order-test
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "target"}
{:block/title "source"
:build/children [{:block/title "nested"}]}]}]})
target (db-test/find-block-by-content @conn "target")
source (db-test/find-block-by-content @conn "source")
nested (db-test/find-block-by-content @conn "nested")
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:move-blocks [[(:db/id nested)]
(:db/id target)
{:sibling? false}]]
[:delete-blocks [[(:db/id source)] {}]]
[:save-block [(assoc (into {} target) :block/title "combined") nil]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [:save-block :insert-blocks :move-blocks]
(mapv first inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-delete-blocks-with-stale-id-keeps-id-test
(testing "delete-blocks derive-history keeps unresolved numeric ids in forward ops"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-uuid (:block/uuid child)
stale-id 99999999
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[(:db/id child) stale-id] {}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn [] tx-meta)]
(is (= [[:delete-blocks [[child-uuid stale-id] {}]]]
forward-outliner-ops)))))
(deftest derive-history-outliner-ops-delete-blocks-prefers-retracted-tx-data-ids-test
(testing "delete-blocks derive-history should prefer tx-data retractEntity ids over stale selection ids"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}]})
child (db-test/find-block-by-content @conn "child")
child-id (:db/id child)
child-uuid (:block/uuid child)
stale-id 99999999
tx-report (d/with @conn [[:db/retractEntity child-id]] {})
tx-meta {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[child-id stale-id] {}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= [[:delete-blocks [[child-uuid] {}]]]
forward-outliner-ops)))))
(deftest derive-history-outliner-ops-move-blocks-resolves-target-id-from-tx-data-test
(testing "move-blocks should resolve stale numeric target id using tx-data block uuid"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "child"}]}]})
child (db-test/find-block-by-content @conn "child")
stale-target-id 9999999
target-uuid (random-uuid)
tx-data [{:e stale-target-id :a :block/uuid :v target-uuid :added false}]
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:move-blocks [[(:db/id child)] stale-target-id {:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= target-uuid
(get-in forward-outliner-ops [0 1 1]))))))
(deftest derive-history-outliner-ops-save-block-resolves-retracted-ref-id-from-db-before-test
(testing "save-block should canonicalize ref attrs using db-before when target ref is retracted in current tx"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "child"}]}]})
child (db-test/find-block-by-content @conn "child")
child-uuid (:block/uuid child)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid child-uuid
:block/tags #{tag-id}}
{}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in forward-outliner-ops [0 1 0 :block/tags]))))))
(deftest derive-history-outliner-ops-insert-blocks-resolves-retracted-ref-id-from-tx-data-test
(testing "insert-blocks should canonicalize block ref attrs when ref entity is retracted in current tx"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "target"}]}]})
target (db-test/find-block-by-content @conn "target")
target-id (:db/id target)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid (random-uuid)
:block/title "new child"
:block/tags #{tag-id}}]
target-id
{:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in forward-outliner-ops [0 1 0 0 :block/tags]))))))
(deftest derive-history-outliner-ops-apply-template-undo-canonicalizes-template-block-refs-test
(testing "apply-template undo explicit inverse should canonicalize nested template-block ref attrs"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "template"}
{:block/title "target"}]}]})
template (db-test/find-block-by-content @conn "template")
target (db-test/find-block-by-content @conn "target")
template-uuid (:block/uuid template)
target-uuid (:block/uuid target)
inserted-uuid (random-uuid)
tag-uuid (random-uuid)
_ (d/transact! conn [{:db/id -1
:block/uuid tag-uuid
:block/title "Tag"
:block/name "tag"
:block/tags :logseq.class/Tag}])
tag-id (:db/id (d/entity @conn [:block/uuid tag-uuid]))
tx-report (d/with @conn [[:db/retractEntity tag-id]] {})
tx-meta {:outliner-op :apply-template
:undo? true
:db-sync/inverse-outliner-ops
[[:apply-template [template-uuid
target-uuid
{:template-blocks [{:block/uuid inserted-uuid
:block/title "inserted"
:block/tags #{tag-id}}]
:sibling? true}]]]}
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn (:db-after tx-report) (:tx-data tx-report) tx-meta)]
(is (= #{[:block/uuid tag-uuid]}
(get-in inverse-outliner-ops [0 1 2 :template-blocks 0 :block/tags]))))))
(deftest derive-history-outliner-ops-apply-template-captures-template-blocks-when-missing-in-op-test
(testing "apply-template forward op should capture concrete template-blocks even if original op omitted them"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "template"
:build/children [{:block/title "template child 1"}
{:block/title "template child 2"}]}
{:block/title "target"}]}]})
template (db-test/find-block-by-content @conn "template")
template-child-1 (db-test/find-block-by-content @conn "template child 1")
template-child-2 (db-test/find-block-by-content @conn "template child 2")
target (db-test/find-block-by-content @conn "target")
inserted-child-1-uuid (random-uuid)
inserted-child-2-uuid (random-uuid)
tx-data [{:e 900001 :a :block/uuid :v inserted-child-1-uuid :added true}
{:e 900002 :a :block/uuid :v inserted-child-2-uuid :added true}]
_ (d/transact! conn [[:db/add (:db/id template-child-1) :block/refs (:db/id template-child-2)]])
tx-meta {:outliner-op :apply-template
:outliner-ops [[:apply-template [(:db/id template)
(:db/id target)
{:sibling? true}]]]}
{:keys [forward-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn @conn tx-data tx-meta)]
(is (= :apply-template (ffirst forward-outliner-ops)))
(is (= true (get-in forward-outliner-ops [0 1 2 :keep-uuid?])))
(is (= [inserted-child-1-uuid inserted-child-2-uuid]
(mapv :block/uuid (get-in forward-outliner-ops [0 1 2 :template-blocks]))))
(is (= #{[:block/uuid inserted-child-2-uuid]}
(get-in forward-outliner-ops [0 1 2 :template-blocks 0 :block/refs]))))))
(deftest derive-history-outliner-ops-builds-delete-page-inverse-for-class-property-and-today-page-test
(testing "delete-page inverse restores hard-retracted class/property/today pages with stable db/ident"
(let [today (date-time-util/ms->journal-day (js/Date.))
conn (db-test/create-conn-with-blocks
{:classes {:Movie {}}
:properties {:rating {:logseq.property/type :number}}
:pages-and-blocks [{:page {:build/journal today}
:blocks [{:block/title "today child"}]}]})
class-page (ldb/get-page @conn "Movie")
property-page (d/entity @conn :user.property/rating)
today-page (db-test/find-journal-by-journal-day @conn today)
today-child (db-test/find-block-by-content @conn "today child")
class-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid class-page) {}]]]}))
property-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid property-page) {}]]]}))
today-inverse (:inverse-outliner-ops
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid today-page) {}]]]}))]
(is (some #(= :create-page (first %)) class-inverse))
(is (some #(= :save-block (first %)) class-inverse))
(is (= (:db/ident class-page)
(get-in (some #(when (= :save-block (first %)) %) class-inverse)
[1 0 :db/ident])))
(is (some #(= :upsert-property (first %)) property-inverse))
(is (some #(= :save-block (first %)) property-inverse))
(is (= (:db/ident property-page)
(get-in (some #(when (= :save-block (first %)) %) property-inverse)
[1 0 :db/ident])))
(is (not-any? #(= :restore-recycled (first %)) today-inverse))
(let [today-insert-op (some #(when (= :insert-blocks (first %)) %) today-inverse)]
(is (some? today-insert-op))
(is (= (:block/uuid today-page)
(get-in today-insert-op [1 1])))
(is (= (:block/uuid today-child)
(get-in today-insert-op [1 0 0 :block/uuid])))))))
(deftest derive-history-outliner-ops-builds-inverse-for-all-supported-ops-test
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1]}}
:properties {:p1 {:logseq.property/type :default}}
:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-a"}
{:block/title "child-b"}]}
{:block/title "prop-block-1"
:build/properties {:p1 "before-1"}}
{:block/title "prop-block-2"}]}]})
page (db-test/find-page-by-title @conn "page")
parent (db-test/find-block-by-content @conn "parent")
child-a (db-test/find-block-by-content @conn "child-a")
child-b (db-test/find-block-by-content @conn "child-b")]
(testing ":save-block"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :save-block
:outliner-ops [[:save-block [{:block/uuid (:block/uuid child-a)
:block/title "changed"} {}]]]})]
(is (= :save-block (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-a)
(get-in inverse-outliner-ops [0 1 0 :block/uuid])))))
(testing ":insert-blocks"
(let [inserted-uuid (random-uuid)
tx-data [{:e 999999 :a :block/uuid :v inserted-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :insert-blocks
:outliner-ops [[:insert-blocks [[{:block/uuid inserted-uuid
:block/title "new"}]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :delete-blocks (ffirst inverse-outliner-ops)))
(is (= [inserted-uuid]
(vec (get-in inverse-outliner-ops [0 1 0]))))))
(testing ":move-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :move-blocks
:outliner-ops [[:move-blocks [[(:db/id child-b)]
(:db/id parent)
{:sibling? false}]]]})]
(is (= :move-blocks (ffirst inverse-outliner-ops)))
(is (= [(:block/uuid child-b)]
(get-in inverse-outliner-ops [0 1 0])))))
(testing ":delete-blocks"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-blocks
:outliner-ops [[:delete-blocks [[(:db/id child-b)] {}]]]})]
(is (= :insert-blocks (ffirst inverse-outliner-ops)))
(is (= (:block/uuid child-b)
(get-in inverse-outliner-ops [0 1 0 0 :block/uuid])))))
(testing ":create-page"
(let [page-uuid (random-uuid)
tx-data [{:e 1 :a :block/title :v "P2" :added true}
{:e 1 :a :block/uuid :v page-uuid :added true}]
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn tx-data {:outliner-op :create-page
:outliner-ops [[:create-page ["P2" {:redirect? false}]]]})]
(is (= [[:delete-page [page-uuid {}]]]
inverse-outliner-ops))))
(testing ":delete-page"
(let [{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :delete-page
:outliner-ops [[:delete-page [(:block/uuid page) {}]]]})]
(is (= [[:restore-recycled [(:block/uuid page)]]]
inverse-outliner-ops))))
(testing ":upsert-property"
(let [property-ident :user.property/test-inverse
expected-page-uuid (common-uuid/gen-uuid :db-ident-block-uuid property-ident)
{:keys [inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops
@conn @conn [] {:outliner-op :upsert-property
:outliner-ops [[:upsert-property [property-ident
{:logseq.property/type :default}
{:property-name "test-inverse"}]]]})]
(is (= [[:delete-page [expected-page-uuid {}]]]
inverse-outliner-ops))))))
(deftest build-history-action-metadata-direct-outdent-builds-indent-outdent-forward-and-inverse-test
(testing "direct outdent keeps canonical indent-outdent forward and inverse ops"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-3 (db-test/find-block-by-content @conn "child-3")
{:keys [tx-data db-after]} (run-direct-outdent conn child-3)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-3)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[(:block/uuid child-3)]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[(:block/uuid child-3)]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))
(deftest derive-history-outliner-ops-direct-outdent-with-extra-moved-blocks-keeps-semantic-ops-test
(testing "direct outdent keeps semantic indent-outdent op and inverse"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child-1"}
{:block/title "child-2"}
{:block/title "child-3"}]}]}]})
child-2 (db-test/find-block-by-content @conn "child-2")
{:keys [tx-data db-after]} (run-direct-outdent conn child-2)
tx-meta {:outliner-op :move-blocks
:outliner-ops [[:indent-outdent-blocks [[(:db/id child-2)]
false
{:parent-original nil
:logical-outdenting? nil}]]]}
{:keys [forward-outliner-ops inverse-outliner-ops]}
(op-construct/derive-history-outliner-ops @conn db-after tx-data tx-meta)]
(is (= [[:indent-outdent-blocks [[(:block/uuid child-2)]
false
{:parent-original nil
:logical-outdenting? nil}]]]
forward-outliner-ops))
(is (= [[:indent-outdent-blocks [[(:block/uuid child-2)]
true
{:parent-original nil
:logical-outdenting? nil}]]]
inverse-outliner-ops)))))

View File

@@ -0,0 +1,251 @@
(ns logseq.outliner.op-test
(:require [clojure.string :as string]
[cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.util.page-ref :as page-ref]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.op :as outliner-op]
[logseq.outliner.property :as outliner-property]))
(deftest toggle-reaction-op
(testing "toggles reactions via outliner ops"
(let [user-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
now 1234]
(ldb/transact! conn
[{:block/uuid user-uuid
:block/name "user"
:block/title "user"
:block/created-at now
:block/updated-at now
:block/tags #{:logseq.class/Page}}]
{})
(let [block (db-test/find-block-by-content @conn "Block")
target-uuid (:block/uuid block)]
(outliner-op/apply-ops! conn
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
{})
(let [block-entity (d/entity @conn [:block/uuid target-uuid])
reactions (:logseq.property.reaction/_target block-entity)
reaction (first reactions)]
(is (= 1 (count reactions)))
(is (uuid? (:block/uuid reaction)))
(is (= "+1" (:logseq.property.reaction/emoji-id reaction)))
(is (= (:db/id (d/entity @conn [:block/uuid user-uuid]))
(:db/id (:logseq.property/created-by-ref reaction)))))
(outliner-op/apply-ops! conn
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
{})
(let [block-entity (d/entity @conn [:block/uuid target-uuid])]
(is (empty? (:logseq.property.reaction/_target block-entity))))))))
(deftest collapse-expand-blocks-op
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Parent"}]}])
block (db-test/find-block-by-content @conn "Parent")
block-id (:block/uuid block)]
(outliner-op/apply-ops!
conn
[[:collapse-expand-blocks [[{:block/uuid block-id
:block/collapsed? true}]
{}]]]
{})
(is (true? (:block/collapsed? (d/entity @conn [:block/uuid block-id]))))
(outliner-op/apply-ops!
conn
[[:collapse-expand-blocks [[{:block/uuid block-id
:block/collapsed? false}]
{}]]]
{})
(is (false? (:block/collapsed? (d/entity @conn [:block/uuid block-id]))))))
(deftest resolve-indent-outdent-parent-original-test
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Original"}]}])
original (db-test/find-block-by-content @conn "Original")
opts (#'outliner-op/resolve-indent-outdent-opts
@conn
{:parent-original {:block/uuid (:block/uuid original)}})]
(is (= (:db/id original) (get-in opts [:parent-original :db/id])))))
(deftest apply-ops-plugin-property-sequence-test
(testing "plugin property ops remain visible after a single apply-ops! batch"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-uuid (:block/uuid block)]
(outliner-op/apply-ops!
conn
[[:upsert-property [:plugin.property._test_plugin/x1 {:logseq.property/type :checkbox
:db/cardinality :db.cardinality/one}
{:property-name :x1}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x1 true]]
[:upsert-property [:plugin.property._test_plugin/x2 {:logseq.property/type :url
:db/cardinality :db.cardinality/one}
{:property-name :x2}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x2 "https://logseq.com"]]
[:upsert-property [:plugin.property._test_plugin/x3 {:logseq.property/type :number
:db/cardinality :db.cardinality/one}
{:property-name :x3}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x3 1]]
[:upsert-property [:plugin.property._test_plugin/x4 {:logseq.property/type :number
:db/cardinality :db.cardinality/many}
{:property-name :x4}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x4 1]]
[:upsert-property [:plugin.property._test_plugin/x5 {:logseq.property/type :json
:db/cardinality :db.cardinality/one}
{:property-name :x5}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x5 "{\"foo\":\"bar\"}"]]
[:upsert-property [:plugin.property._test_plugin/x6 {:logseq.property/type :page
:db/cardinality :db.cardinality/one}
{:property-name :x6}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x6 "Page x"]]
[:upsert-property [:plugin.property._test_plugin/x7 {:logseq.property/type :page
:db/cardinality :db.cardinality/many}
{:property-name :x7}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x7 "Page y"]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x7 "Page z"]]
[:upsert-property [:plugin.property._test_plugin/x8 {:logseq.property/type :default
:db/cardinality :db.cardinality/one}
{:property-name :x8}]]
[:set-block-property [block-uuid :plugin.property._test_plugin/x8 "some content"]]]
{})
(let [block' (d/entity @conn [:block/uuid block-uuid])]
(is (true? (:plugin.property._test_plugin/x1 block')))
(is (= "https://logseq.com"
(:block/title (:plugin.property._test_plugin/x2 block'))))
(is (= 1
(:logseq.property/value (:plugin.property._test_plugin/x3 block'))))
(is (= #{1}
(set (map :logseq.property/value (:plugin.property._test_plugin/x4 block')))))
(is (= "{\"foo\":\"bar\"}" (:plugin.property._test_plugin/x5 block')))
(is (= "page x"
(:block/name (:plugin.property._test_plugin/x6 block'))))
(is (= #{"page y" "page z"}
(set (map :block/name (:plugin.property._test_plugin/x7 block')))))
(is (= "some content"
(:block/title (:plugin.property._test_plugin/x8 block'))))))))
(deftest remove-block-property-op-rejects-lookup-ref-block-id-test
(testing "remove-block-property rejects lookup-ref block ids"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-uuid (:block/uuid block)]
(outliner-property/set-block-property! conn
[:block/uuid block-uuid]
:logseq.property/order-list-type
"number")
(is (some? (:logseq.property/order-list-type
(d/entity @conn [:block/uuid block-uuid]))))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:remove-block-property [[:block/uuid block-uuid]
:logseq.property/order-list-type]]]
{}))))))
(deftest direct-plugin-many-page-property-appends-values-test
(testing "direct property operations keep both page values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-id (:db/id block)
property-id :plugin.property._test_plugin/x7]
(outliner-property/upsert-property! conn property-id
{:logseq.property/type :page
:db/cardinality :db.cardinality/many}
{:property-name :x7})
(outliner-property/set-block-property! conn block-id property-id "Page y")
(outliner-property/set-block-property! conn block-id property-id "Page z")
(is (= #{"page y" "page z"}
(set (map :block/name
(:plugin.property._test_plugin/x7 (d/entity @conn block-id)))))))))
(deftest apply-template-op-resolves-dynamic-variables-test
(testing "apply-template resolves dynamic variables in block title and property values"
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "Target Page"}
:blocks [{:block/title "target block"}]}
{:page {:block/title "Templates"}
:blocks [{:block/title "template root"
:build/children [{:block/title "page is <% current page %>"}
{:block/title "time block"
:build/properties {:log-time "<%time%>"}}]}]}]
:properties {:log-time {:logseq.property/type :default}}})
target-page (ldb/get-page @conn "Target Page")
template-root (db-test/find-block-by-content @conn "template root")
target-block (db-test/find-block-by-content @conn "target block")
template-blocks (->> (ldb/get-block-and-children @conn (:block/uuid template-root)
{:include-property-block? true})
rest)
blocks-to-insert (cons (assoc (into {} (first template-blocks))
:db/id (:db/id (first template-blocks))
:logseq.property/used-template (:db/id template-root))
(map (fn [block]
(assoc (into {} block) :db/id (:db/id block)))
(rest template-blocks)))
_ (outliner-op/apply-ops! conn
[[:apply-template [(:block/uuid template-root)
(:block/uuid target-block)
{:template-blocks blocks-to-insert}]]]
{})
page-var-block (db-test/find-block-by-content
@conn
(str "page is " (page-ref/->page-ref (:block/uuid target-page))))
time-block (some->> (d/q '[:find [?b ...]
:in $ ?title ?page-title
:where
[?b :block/title ?title]
[?b :block/page ?page]
[?page :block/title ?page-title]]
@conn "time block" "Target Page")
first
(d/entity @conn))
time-value (some (fn [[property-id value]]
(when (= "log-time" (name property-id))
value))
(db-test/readable-properties time-block))]
(is (some? page-var-block))
(is (string? time-value))
(is (not (string/blank? time-value)))
(is (not (string/includes? time-value "<%"))))))
(deftest apply-ops-requires-uuid-block-ids-and-keyword-property-ids-test
(testing "ops reject integer eids and accept UUID/keyword identifiers"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "Test"}
:blocks [{:block/title "Block"}]}])
block (db-test/find-block-by-content @conn "Block")
block-id (:db/id block)
block-uuid (:block/uuid block)
property-kw :plugin.property._test_plugin/normalized-prop]
(outliner-property/upsert-property! conn property-kw
{:logseq.property/type :checkbox
:db/cardinality :db.cardinality/one}
{:property-name :normalized-prop})
(let [property-id (:db/id (d/entity @conn property-kw))]
(outliner-op/apply-ops!
conn
[[:set-block-property [block-uuid property-kw true]]]
{})
(is (true? (property-kw (d/entity @conn [:block/uuid block-uuid]))))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:set-block-property [block-id property-kw true]]]
{})))
(is (thrown? js/Error
(outliner-op/apply-ops!
conn
[[:set-block-property [block-uuid property-id true]]]
{})))))))

View File

@@ -0,0 +1,206 @@
(ns logseq.outliner.page-test
(:require [cljs.test :refer [deftest is testing]]
[datascript.core :as d]
[logseq.common.config :as common-config]
[logseq.common.util :as common-util]
[logseq.common.util.date-time :as date-time-util]
[logseq.common.util.page-ref :as page-ref]
[logseq.common.uuid :as common-uuid]
[logseq.db :as ldb]
[logseq.db.common.order :as db-order]
[logseq.db.frontend.db :as db-db]
[logseq.db.test.helper :as db-test]
[logseq.outliner.page :as outliner-page]))
(deftest create-class
(let [conn (db-test/create-conn)
_ (outliner-page/create! conn "movie" {:class? true})
_ (outliner-page/create! conn "Movie" {:class? true})
movie-class (ldb/get-case-page @conn "movie")
Movie-class (ldb/get-case-page @conn "Movie")]
(is (ldb/class? movie-class) "Creates a class")
(is (ldb/class? Movie-class) "Creates another class with a different case sensitive name")
(is (not= movie-class Movie-class) "The two classes are not the same")))
(deftest create-namespace-pages
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/property1 {:logseq.property/type :default}}
:classes {:class1 {}}
:pages-and-blocks [{:page {:block/title "page1"}}]})]
(testing "Basic valid workflows"
(let [[_ child-uuid] (outliner-page/create! conn "foo/bar/baz" {:split-namespace? true})
child-page (d/entity @conn [:block/uuid child-uuid])
;; Create a 2nd child page using existing parent pages
[_ child-uuid2] (outliner-page/create! conn "foo/bar/baz2" {:split-namespace? true})
child-page2 (d/entity @conn [:block/uuid child-uuid2])
;; Create a child page for a class
[_ child-uuid3] (outliner-page/create! conn "c1/c2" {:split-namespace? true :class? true})
child-page3 (d/entity @conn [:block/uuid child-uuid3])
library (ldb/get-built-in-page @conn common-config/library-page-name)
bar (ldb/get-page @conn "bar")]
(is (= ["foo"] (map :block/title (:block/_parent library)))
"Namespace (non-class) pages are added to the Library page")
(is (= ["baz" "baz2"] (map :block/title (:block/_parent bar)))
"Child pages are created under the same parent")
(is (= ["foo" "bar"] (map :block/title [(:block/parent (:block/parent child-page))
(:block/parent child-page)]))
"Child page with new parent has correct parents")
(is (= (map :block/uuid (db-db/get-page-parents child-page))
(map :block/uuid (db-db/get-page-parents child-page2)))
"Child page with existing parents has correct parents")
(is (= #{"Root Tag" "c1"} (set (map :block/title (ldb/get-classes-parents [child-page3]))))
"Child class with new parent has correct parents")
(outliner-page/create! conn "foo/class1/baz3" {:split-namespace? true})
(is (= #{"Tag" "Page"}
(set (d/q '[:find [?tag-title ...]
:where
[?b :block/title "class1"]
[?b :block/tags ?t]
[?t :block/title ?tag-title]] @conn)))
"Using an existing class page in a multi-parent namespace doesn't allow a page to have a class parent and instead creates a new page")))
(testing "Child pages with same name and different parents"
(let [_ (outliner-page/create! conn "vim/keys" {:split-namespace? true})
_ (outliner-page/create! conn "emacs/keys" {:split-namespace? true})]
(is (= #{"vim" "emacs"}
(->> (d/q '[:find [(pull ?b [{:block/parent [:block/title]}]) ...] :where [?b :block/title "keys"]] @conn)
(map #(get-in % [:block/parent :block/title]))
set))
"Two child pages with same name exist and have different parents")))
(testing "Invalid workflows"
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "class1/page" {:split-namespace? true}))
"Page can't have a class parent")
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "property1/page" {:split-namespace? true}))
"Page can't have a property parent")
(is (thrown-with-msg?
js/Error
#"Cannot create"
(outliner-page/create! conn "property1/class" {:split-namespace? true :class? true}))
"Class can't have a property parent"))))
(deftest create-page
(let [conn (db-test/create-conn)
[_ page-uuid] (outliner-page/create! conn "fooz" {})]
(is (= "fooz" (:block/title (d/entity @conn [:block/uuid page-uuid])))
"Page created correctly")
(is (thrown-with-msg?
js/Error
#"can't include \"/"
(outliner-page/create! conn "foo/bar" {}))
"Page can't have '/'n title")
(is (thrown-with-msg?
js/Error
#"can't include \"#\""
(outliner-page/create! conn "foo#bar" {}))
"Page can't have '#' in title")
(is (thrown-with-msg?
js/Error
#"can't include \"#\""
(outliner-page/create! conn "#tagstyle" {}))
"Page can't have leading '#' in title")))
(deftest delete-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "D1"}
:blocks [{:block/title "b1"}]}])
d1 (ldb/get-page @conn "D1")
b1 (db-test/find-block-by-content @conn "b1")]
(ldb/transact! conn [{:db/id (:db/id b1)
:block/title (str "b1 " (page-ref/->page-ref (:block/uuid d1)))
:block/refs #{(:db/id d1)}}])
(is (contains? (set (map :db/id (:block/refs (d/entity @conn (:db/id b1)))))
(:db/id d1)))
(outliner-page/delete! conn (:block/uuid d1))
(let [d1' (d/entity @conn (:db/id d1))
b1' (d/entity @conn (:db/id b1))
recycle-page (ldb/get-built-in-page @conn "Recycle")]
(is (some? d1'))
(is (some? b1'))
(is (= (:block/uuid recycle-page) (:block/uuid (:block/parent d1'))))
(is (integer? (:logseq.property/deleted-at d1')))
(is (nil? (:block/raw-title b1')))
(is (contains? (set (map :db/id (:block/refs b1')))
(:db/id d1)))
(is (= (:block/uuid d1') (:block/uuid (:block/page b1')))))))
(deftest delete-class-page-hard-retracts-page-tree
(let [conn (db-test/create-conn-with-blocks {:classes {:Movie {}}})
class-page (ldb/get-page @conn "Movie")
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "class child"
:block/page (:db/id class-page)
:block/parent (:db/id class-page)
:block/order (db-order/gen-key)}])]
(outliner-page/delete! conn (:block/uuid class-page))
(is (nil? (d/entity @conn [:block/uuid (:block/uuid class-page)])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest delete-property-page-hard-retracts-page-tree
(let [conn (db-test/create-conn-with-blocks {:properties {:rating {:logseq.property/type :number}}})
property-page (d/entity @conn :user.property/rating)
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "property child"
:block/page (:db/id property-page)
:block/parent (:db/id property-page)
:block/order (db-order/gen-key)}])]
(outliner-page/delete! conn (:block/uuid property-page))
(is (nil? (d/entity @conn :user.property/rating)))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest create-journal
(let [conn (db-test/create-conn)
[_ page-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})]
(is (= "Dec 16th, 2024" (:block/title (d/entity @conn [:block/uuid page-uuid])))
"Journal created correctly")
(is (= [:logseq.class/Journal]
(->> (d/entity @conn [:block/uuid page-uuid])
:block/tags
(map #(:db/ident (d/entity @conn (:db/id %))))))
"New journal only has Journal tag")))
(deftest create-journal-keeps-default-block-name-with-custom-title-format
(let [conn (db-test/create-conn)
_ (d/transact! conn [[:db/add :logseq.class/Journal :logseq.property.journal/title-format "yyyy-MM-dd EEEE"]])
[_ page-uuid] (outliner-page/create! conn "Dec 16th, 2024" {})
page (d/entity @conn [:block/uuid page-uuid])
default-name (-> (:block/journal-day page)
(date-time-util/int->journal-title date-time-util/default-journal-title-formatter)
common-util/page-name-sanity-lc)]
(is (= "2024-12-16 Monday" (:block/title page))
"Journal title follows configured formatter")
(is (= default-name (:block/name page))
"Journal block/name remains the default formatter, independent of title format")))
(deftest create-slash-formatted-journal-does-not-create-namespace-pages
(let [conn (db-test/create-conn)
_ (d/transact! conn [[:db/add :logseq.class/Journal :logseq.property.journal/title-format "yyyy/MM/dd"]])
[_ page-uuid] (outliner-page/create! conn "May 18th, 2026" {:split-namespace? true
:journal? true})
page (d/entity @conn [:block/uuid page-uuid])]
(is (= "2026/05/18" (:block/title page))
"Journal title follows slash title format")
(is (= (common-uuid/gen-uuid :journal-page-uuid 20260518) (:block/uuid page))
"Journal page has the standard journal uuid")
(is (nil? (ldb/get-page @conn "2026"))
"Journal title is not split into a year namespace page")
(is (nil? (ldb/get-page @conn "05"))
"Journal title is not split into a month namespace page")
(is (nil? (ldb/get-page @conn "18"))
"Journal title is not split into a day namespace page")))

View File

@@ -0,0 +1,47 @@
(ns logseq.outliner.pipeline-test
(:require [cljs.test :refer [deftest is]]
[datascript.core :as d]
[logseq.common.util.page-ref :as page-ref]
[logseq.db.test.helper :as db-test]
[logseq.outliner.pipeline :as outliner-pipeline]))
(deftest block-content-refs
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"} :blocks [{:block/title "b1"}]}])
block (db-test/find-block-by-content @conn "b1")]
(assert block)
(is (= [(:db/id block)]
(outliner-pipeline/block-content-refs @conn
{:block/title (str "ref to " (page-ref/->page-ref (:block/uuid block)))})))))
(deftest db-rebuild-block-refs-for-query-block
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "Todo query"
:build/tags [:logseq.class/Query]
:build/properties
{:logseq.property/query
{:build/property-value :block
:block/title "(task Todo)"}}}]}]})
block (db-test/find-block-by-content @conn "Todo query")
refs (set (outliner-pipeline/db-rebuild-block-refs @conn block))
query-property-id (:db/id (d/entity @conn :logseq.property/query))
query-class-id (:db/id (d/entity @conn :logseq.class/Query))]
(is (some? query-property-id)
"Sanity: :logseq.property/query entity exists")
(is (contains? refs query-class-id)
"#Query class tag is included in :block/refs")
(is (not (contains? refs query-property-id))
"#Query block does not reference logseq.property/query through :block/refs")))
(deftest db-rebuild-block-refs-removes-recursive-self-ref
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "self"}]}])
block (db-test/find-block-by-content @conn "self")
block' (assoc (d/pull @conn '[:db/id :block/uuid :block/title] (:db/id block))
:block/title
(str "self " (page-ref/->page-ref (:block/uuid block))))]
(is (empty? (outliner-pipeline/db-rebuild-block-refs @conn block'))
"A block should not rebuild a recursive ref to itself")))

View File

@@ -0,0 +1,694 @@
(ns logseq.outliner.property-test
(:require [cljs.test :refer [deftest is testing are]]
[datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.frontend.property :as db-property]
[logseq.db.test.helper :as db-test]
[logseq.outliner.core :as outliner-core]
[logseq.outliner.property :as outliner-property]))
(deftest upsert-property!
(testing "Creates a property"
(let [conn (db-test/create-conn-with-blocks [])
_ (outliner-property/upsert-property! conn nil {:logseq.property/type :number} {:property-name "num"})]
(is (= :number
(:logseq.property/type (d/entity @conn :user.property/num)))
"Creates property with property-name")))
(testing "Updates a property"
(let [conn (db-test/create-conn-with-blocks {:properties {:num {:logseq.property/type :number}}})
old-updated-at (:block/updated-at (d/entity @conn :user.property/num))]
(testing "and change its cardinality"
(outliner-property/upsert-property! conn :user.property/num {:db/cardinality :many} {})
(is (db-property/many? (d/entity @conn :user.property/num)))
(is (> (:block/updated-at (d/entity @conn :user.property/num))
old-updated-at)))
(testing "and change its type from a ref to a non-ref type"
(outliner-property/upsert-property! conn :user.property/num {:logseq.property/type :checkbox} {})
(is (= :checkbox (:logseq.property/type (d/entity @conn :user.property/num))))
(is (= nil (:db/valueType (d/entity @conn :user.property/num)))))))
(testing "Multiple properties that generate the same initial :db/ident"
(let [conn (db-test/create-conn-with-blocks [])]
(outliner-property/upsert-property! conn nil {:logseq.property/type :default} {:property-name "p1"})
(outliner-property/upsert-property! conn nil {} {:property-name "p1"})
(outliner-property/upsert-property! conn nil {} {:property-name "p1"})
(is (= {:block/name "p1" :block/title "p1" :logseq.property/type :default}
(select-keys (d/entity @conn :user.property/p1) [:block/name :block/title :logseq.property/type]))
"Existing db/ident does not get modified")
(is (= "p1"
(:block/title (d/entity @conn :user.property/p1-1)))
"2nd property gets unique ident")
(is (= "p1"
(:block/title (d/entity @conn :user.property/p1-2)))
"3rd property gets unique ident"))))
(deftest upsert-property-rejects-type-change-with-existing-data
(testing "Changing type is rejected when property has values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:status "active"}}]}])]
(is (thrown-with-msg?
js/Error #"Disallowed type change with existing data"
(outliner-property/upsert-property! conn :user.property/status {:logseq.property/type :number} {})))))
(testing "Changing type is allowed when property has no values"
(let [conn (db-test/create-conn-with-blocks {:properties {:empty-prop {:logseq.property/type :default}}})]
(outliner-property/upsert-property! conn :user.property/empty-prop {:logseq.property/type :number} {})
(is (= :number (:logseq.property/type (d/entity @conn :user.property/empty-prop)))))))
(deftest convert-property-input-string
(testing "Convert property input string according to its schema type"
(let [test-uuid (random-uuid)]
(are [x y]
(= (let [[schema-type value] x]
(outliner-property/convert-property-input-string nil {:logseq.property/type schema-type} value)) y)
[:number "1"] 1
[:number "1.2"] 1.2
[:url test-uuid] test-uuid
[:date test-uuid] test-uuid
[:any test-uuid] test-uuid
[nil test-uuid] test-uuid))))
(deftest create-property-text-block!
(testing "Create a new :default property value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "foo"}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/default "" {})
new-property-value (:user.property/default (db-test/find-block-by-content @conn "b2"))]
(is (some? (:db/id new-property-value)) "New property value created")
(is (= "" (db-property/property-value-content new-property-value))
"Property value has correct content")
(is (= :user.property/default
(get-in (d/entity @conn (:db/id new-property-value)) [:logseq.property/created-from-property :db/ident]))
"Has correct created-from-property")))
(testing "Create cases for a new :one :number property value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num "3" {})
new-property-value (:user.property/num (db-test/find-block-by-content @conn "b2"))]
(is (some? (:db/id new-property-value)) "New property value created")
(is (= 3 (db-property/property-value-content new-property-value))
"Property value has correct content")
(is (= :user.property/num
(get-in (d/entity @conn (:db/id new-property-value)) [:logseq.property/created-from-property :db/ident]))
"Has correct created-from-property")
(is (thrown-with-msg?
js/Error
#"Can't convert"
(outliner-property/create-property-text-block! conn (:db/id block) :user.property/num "Not a number" {}))
"Wrong value isn't transacted")))
(testing "Create new :many :number property values"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num-many #{2}}}
{:block/title "b2"}]}])
block (db-test/find-block-by-content @conn "b2")
;; Use same args as outliner.op
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "3" {})
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "4" {})
_ (outliner-property/create-property-text-block! conn (:db/id block) :user.property/num-many "5" {})
new-property-values (:user.property/num-many (db-test/find-block-by-content @conn "b2"))]
(is (seq new-property-values) "New property values created")
(is (= #{3 4 5} (set (map db-property/property-value-content new-property-values)))
"Property value has correct content"))))
(deftest set-block-property-basic-cases
(testing "Set a :number value with existing value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2"}]}])
property-value (:user.property/num (db-test/find-block-by-content @conn "b1"))
_ (assert (:db/id property-value))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num (:db/id property-value))]
(is (= (:db/id property-value)
(:db/id (:user.property/num (db-test/find-block-by-content @conn "b2")))))
(let [empty-placeholder-id (:db/id (d/entity @conn :logseq.property/empty-placeholder))]
(outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num empty-placeholder-id)
(is (= empty-placeholder-id
(:logseq.property/value (:user.property/num (d/entity @conn [:block/uuid block-uuid]))))))))
(testing "Update a :number value with existing value"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 2}}
{:block/title "b2" :build/properties {:num 3}}]}])
property-value (:user.property/num (db-test/find-block-by-content @conn "b1"))
_ (assert (:db/id property-value))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/num (:db/id property-value))]
(is (= (:db/id property-value)
(:db/id (:user.property/num (db-test/find-block-by-content @conn "b2"))))))))
(deftest set-block-property-with-non-ref-values
(testing "Setting :default with same property value reuses existing entity"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:logseq.property/order-list-type "number"}}
{:block/title "b2"}]}])
property-value (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b1"))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :logseq.property/order-list-type "number")]
(is (some? (:db/id (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b2"))))
"New block has property set")
(is (= (:db/id property-value)
(:db/id (:logseq.property/order-list-type (db-test/find-block-by-content @conn "b2")))))))
(testing "Setting :checkbox with same property value reuses existing entity"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:checkbox true}}
{:block/title "b2"}]}])
property-value (:user.property/checkbox (db-test/find-block-by-content @conn "b1"))
block-uuid (:block/uuid (db-test/find-block-by-content @conn "b2"))
;; Use same args as outliner.op
_ (outliner-property/set-block-property! conn [:block/uuid block-uuid] :user.property/checkbox true)]
(is (true? (:user.property/checkbox (db-test/find-block-by-content @conn "b2")))
"New block has property set")
(is (= property-value (:user.property/checkbox (db-test/find-block-by-content @conn "b2")))))))
(deftest remove-block-property!
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:default "foo"}}]}])
block (db-test/find-block-by-content @conn "b1")
_ (assert (:user.property/default block))
;; Use same args as outliner.op
_ (outliner-property/remove-block-property! conn [:block/uuid (:block/uuid block)] :user.property/default)
updated-block (db-test/find-block-by-content @conn "b1")]
(is (some? updated-block))
(is (nil? (:user.property/default updated-block)) "Block property is deleted")))
(deftest batch-set-property!
(testing "Set built-in property values for multiple blocks"
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "item 1"}
{:block/title "item 2"}]}])
block-ids (map #(-> (db-test/find-block-by-content @conn %) :block/uuid) ["item 1" "item 2"])
_ (outliner-property/batch-set-property! conn block-ids :logseq.property/order-list-type "number")
updated-blocks (map #(db-test/find-block-by-content @conn %) ["item 1" "item 2"])]
(is (= ["number" "number"]
(map #(db-property/property-value-content (:logseq.property/order-list-type %))
updated-blocks))
"Property values are batch set")))
(testing "Set custom default-many property values from string vector"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
block-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(outliner-property/batch-set-property! conn [block-id] :user.property/reproducible-steps ["Step 1" "Step 2" "Step 3"])
(is (= #{"Step 1" "Step 2" "Step 3"}
(->> (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-id]))
(map db-property/property-value-content)
set))
"String vector values are persisted as many property values")))
(testing "Set custom default-many property values from id vector"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "source"
:build/properties {:user.property/reproducible-steps #{"Step 1" "Step 2" "Step 3"}}}
{:block/title "target"}]}]})
source-values (->> (:user.property/reproducible-steps (db-test/find-block-by-content @conn "source"))
(map :db/id)
vec)
target-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(outliner-property/batch-set-property! conn [target-id] :user.property/reproducible-steps source-values)
(is (= #{"Step 1" "Step 2" "Step 3"}
(->> (:user.property/reproducible-steps (d/entity @conn [:block/uuid target-id]))
(map db-property/property-value-content)
set))
"Id vector values are persisted as many property values")))
(testing "Invalid many values throw and don't partially persist"
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
block-id (:block/uuid (db-test/find-block-by-content @conn "target"))]
(is (thrown-with-msg?
js/Error
#"Schema validation failed"
(outliner-property/batch-set-property! conn [block-id] :user.property/reproducible-steps [999999])))
(is (nil? (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-id])))
"No partial values are persisted on failure"))))
(deftest status-property-setting-classes
(let [conn (db-test/create-conn-with-blocks
{:classes {:Cat {}
:Project {:build/class-properties [:logseq.property/status]}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title ""}
{:block/title "cat task" :build/tags [:Cat]}
{:block/title "project task" :build/tags [:Project]}]}]})
page1 (:block/uuid (db-test/find-page-by-title @conn "page1"))
[empty-task cat-task project]
(map #(:block/uuid (db-test/find-block-by-content @conn %)) ["" "cat task" "project task"])]
(outliner-property/batch-set-property! conn [empty-task] :logseq.property/status :logseq.property/status.doing)
(is (= [:logseq.class/Task]
(mapv :db/ident (:block/tags (d/entity @conn [:block/uuid empty-task]))))
"Adds Task to block when it is not tagged")
(outliner-property/batch-set-property! conn [page1] :logseq.property/status :logseq.property/status.doing)
(is (= #{:logseq.class/Task :logseq.class/Page}
(set (map :db/ident (:block/tags (d/entity @conn [:block/uuid page1])))))
"Adds Task to page without tag")
(outliner-property/batch-set-property! conn [cat-task] :logseq.property/status :logseq.property/status.doing)
(is (= #{:logseq.class/Task :user.class/Cat}
(set (map :db/ident (:block/tags (d/entity @conn [:block/uuid cat-task])))))
"Adds Task to tagged block when its classes do not provide status")
(outliner-property/batch-set-property! conn [project] :logseq.property/status :logseq.property/status.doing)
(is (= [:user.class/Project]
(mapv :db/ident (:block/tags (d/entity @conn [:block/uuid project]))))
"Doesn't add Task to block when its class provides status")))
(deftest batch-set-property-rejects-private-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/empty-placeholder)
prop (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-property/batch-set-property! conn [(:db/id placeholder)] (:db/ident prop) "hacked"))))))
(deftest batch-remove-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:C1 {}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "item 1" :build/properties {:logseq.property/order-list-type "number"}}
{:block/title "item 2" :build/properties {:logseq.property/order-list-type "number"}}]}]})
block-ids (map #(-> (db-test/find-block-by-content @conn %) :block/uuid) ["item 1" "item 2"])
_ (outliner-property/batch-remove-property! conn block-ids :logseq.property/order-list-type)
updated-blocks (map #(db-test/find-block-by-content @conn %) ["item 1" "item 2"])]
(is (= [nil nil]
(map :logseq.property/order-list-type updated-blocks))
"Property values are batch removed")
(is (thrown-with-msg?
js/Error
#"Can't remove private"
(outliner-property/batch-remove-property! conn [(:db/id (db-test/find-page-by-title @conn "page1"))] :block/tags)))
(is (thrown-with-msg?
js/Error
#"Can't remove required"
(outliner-property/batch-remove-property! conn [(:db/id (d/entity @conn :user.class/C1))] :logseq.property.class/extends)))))
(deftest batch-remove-property-rejects-private-built-in-entity
(let [conn (db-test/create-conn)
placeholder (d/entity @conn :logseq.property/empty-placeholder)
prop (d/entity @conn :logseq.property/description)]
(is (thrown-with-msg? js/Error #"Built-in.*can't be modified"
(db-test/silence-stderr
(outliner-property/batch-remove-property! conn [(:db/id placeholder)] (:db/ident prop)))))))
(deftest add-existing-values-to-closed-values!
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :build/properties {:num 1}}
{:block/title "b2" :build/properties {:num 2}}]}])
values (map (fn [d] (:block/uuid (d/entity @conn (:v d)))) (d/datoms @conn :avet :user.property/num))
_ (outliner-property/add-existing-values-to-closed-values! conn :user.property/num values)]
(is (= [1 2]
(map db-property/closed-value-content (:block/_closed-value-property (d/entity @conn :user.property/num)))))))
(deftest add-existing-generated-value-to-closed-values-reparents-to-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:user.property/reproducible-steps {:logseq.property/type :default
:db/cardinality :db.cardinality/many
:logseq.property/public? true}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}]})
property (d/entity @conn :user.property/reproducible-steps)
block (db-test/find-block-by-content @conn "target")
block-uuid (:block/uuid block)]
(outliner-property/batch-set-property! conn [block-uuid] :user.property/reproducible-steps ["Step 1"])
(let [generated-value (first (:user.property/reproducible-steps (d/entity @conn [:block/uuid block-uuid])))
value-uuid (:block/uuid generated-value)]
(is (= (:db/id block) (:db/id (:block/parent generated-value))))
(outliner-property/add-existing-values-to-closed-values! conn :user.property/reproducible-steps [value-uuid])
(let [closed-value (d/entity @conn [:block/uuid value-uuid])]
(is (= #{(:db/id property)}
(set (map :db/id (:block/closed-value-property closed-value)))))
(is (= (:db/id property) (:db/id (:block/parent closed-value))))
(is (= (:db/id property) (:db/id (:block/page closed-value))))
(is (= "Step 1" (db-property/closed-value-content closed-value)))
(outliner-core/delete-blocks! conn [(d/entity @conn [:block/uuid block-uuid])] {})
(is (some? (d/entity @conn [:block/uuid value-uuid])))))))
(deftest upsert-closed-value!
(let [conn (db-test/create-conn-with-blocks
{:properties {:num {:build/closed-values [{:uuid (random-uuid) :value 2}]
:logseq.property/type :number}}})]
(testing "Add non-number choice shouldn't work"
(is
(thrown-with-msg?
js/Error
#"Can't convert"
(outliner-property/upsert-closed-value! conn :user.property/num {:value "not a number"}))))
(testing "Can't add existing choice"
(is
(thrown-with-msg?
js/Error
#"Closed value choice already exists"
(outliner-property/upsert-closed-value! conn :user.property/num {:value 2}))))
(testing "Add choice successfully"
(let [_ (outliner-property/upsert-closed-value! conn :user.property/num {:value 3})
b (first (d/q '[:find [(pull ?b [*]) ...] :where [?b :logseq.property/value 3]] @conn))]
(is (ldb/closed-value? (d/entity @conn (:db/id b))))
(is (= [2 3]
(map db-property/closed-value-content (:block/_closed-value-property (d/entity @conn :user.property/num)))))))
(testing "Update choice successfully"
(let [b (first (d/q '[:find [(pull ?b [*]) ...] :where [?b :logseq.property/value 2]] @conn))
_ (outliner-property/upsert-closed-value! conn :user.property/num {:id (:block/uuid b)
:value 4
:description "choice 4"})
updated-b (d/entity @conn [:block/uuid (:block/uuid b)])]
(is (= 4 (db-property/closed-value-content updated-b)))
(is (= "choice 4" (db-property/property-value-content (:logseq.property/description updated-b))))))))
(deftest delete-closed-value!
(let [closed-value-uuid (random-uuid)
used-closed-value-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
{:properties {:default {:build/closed-values [{:uuid closed-value-uuid :value "foo"}
{:uuid used-closed-value-uuid :value "bar"}]
:logseq.property/type :default}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1" :user.property/default [:block/uuid used-closed-value-uuid]}]}]})
_ (assert (:user.property/default (db-test/find-block-by-content @conn "b1")))
property-id (:db/id (d/entity @conn :user.property/default))
closed-value-id (:db/id (d/entity @conn [:block/uuid closed-value-uuid]))
child-uuid (random-uuid)
_ (d/transact! conn [{:block/uuid child-uuid
:block/title "closed value child"
:block/page property-id
:block/parent closed-value-id
:block/order "a0"}])
_ (outliner-property/delete-closed-value! conn property-id [:block/uuid closed-value-uuid])]
(is (nil? (d/entity @conn [:block/uuid closed-value-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))
(is (= [used-closed-value-uuid]
(mapv :block/uuid (:block/_closed-value-property (d/entity @conn :user.property/default)))))))
(deftest class-add-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {}}
:properties {:p1 {:logseq.property/type :default}
:p2 {:logseq.property/type :default}}})
_ (outliner-property/class-add-property! conn :user.class/c1 :user.property/p1)
_ (outliner-property/class-add-property! conn :user.class/c1 :user.property/p2)]
(is (= [:user.property/p1 :user.property/p2]
(map :db/ident (:logseq.property.class/properties (d/entity @conn :user.class/c1)))))))
(deftest class-remove-property!
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1 :p2]}}})
_ (outliner-property/class-remove-property! conn :user.class/c1 :user.property/p1)]
(is (= [:user.property/p2]
(map :db/ident (:logseq.property.class/properties (d/entity @conn :user.class/c1)))))))
(deftest get-block-classes-properties
(let [conn (db-test/create-conn-with-blocks
{:classes {:c1 {:build/class-properties [:p1]}
:c2 {:build/class-properties [:p2 :p3]}}
:pages-and-blocks
[{:page {:block/title "p1"}
:blocks [{:block/title "o1"
:build/tags [:c1 :c2]}]}]})
block (db-test/find-block-by-content @conn "o1")]
(is (= [:user.property/p1 :user.property/p2 :user.property/p3]
(map :db/ident (:classes-properties (outliner-property/get-block-classes-properties @conn (:db/id block))))))))
(deftest property-with-other-position-default-bottom-rules
(testing "explicit non-properties ui-position remains other-position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number
:logseq.property/ui-position :block-left}))))
(testing "number property without explicit ui-position defaults to bottom position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number}))))
(testing "default property without closed values stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :default
:property/closed-values []}))))
(testing "default property with closed values is positioned in bottom row"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :default
:property/closed-values [{:db/id 1}]}))))
(testing "url property without explicit ui-position stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :url}))))
(testing "explicit left/right ui-position remains in positioned rows"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :url
:logseq.property/ui-position :block-left}))))
(testing "explicit properties ui-position keeps property in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :number
:logseq.property/ui-position :properties}))))
(testing "many node property without explicit ui-position defaults to bottom position"
(is (true?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :user.property/p1
:logseq.property/type :node
:db/cardinality :db.cardinality/many}))))
(testing "bidirectional config property stays in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property.class/enable-bidirectional?
:logseq.property/type :checkbox}))))
(testing "tag properties and schema-related properties stay in normal property rows"
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :block/tags
:logseq.property/type :class})))
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property.class/properties
:logseq.property/type :property})))
(is (false?
(outliner-property/property-with-other-position?
nil
{}
{:db/ident :logseq.property/public?
:logseq.property/type :checkbox}))))
(testing "a schema property is part of the shared schema set"
(is (contains? db-property/schema-properties :logseq.property/public?))))
(deftest get-block-positioned-properties-filters-non-public
(let [conn (db-test/create-conn-with-blocks [])
journal-class (d/entity @conn :logseq.class/Journal)
positioned-properties (outliner-property/get-block-positioned-properties @conn (:db/id journal-class) :block-below)]
(is (every? #(not (false? (:logseq.property/public? %))) positioned-properties))
(is (not-any? #(= :logseq.property.journal/title-format (:db/ident %))
positioned-properties))))
(deftest get-block-positioned-properties-keeps-empty-inherited-tag-properties
(let [conn (db-test/create-conn-with-blocks
{:properties {:authors {:logseq.property/type :node
:db/cardinality :db.cardinality/many}}
:classes {:Paper {:build/class-properties [:authors]}}
:pages-and-blocks [{:page {:block/title "page1"}
:blocks [{:block/title "paper1"
:build/tags [:Paper]}]}]})
paper1 (db-test/find-block-by-content @conn "paper1")
positioned-properties (outliner-property/get-block-positioned-properties @conn (:db/id paper1) :block-below)
positioned-idents (set (map :db/ident positioned-properties))]
(is (contains? positioned-idents :user.property/authors))))
(deftest extends-cycle
(testing "Fail when creating a cycle of extends"
(let [conn (db-test/create-conn-with-blocks
{:classes {:Class1 {}
:Class2 {}
:Class3 {}}})
db @conn
class1 (d/entity db :user.class/Class1)
class2 (d/entity db :user.class/Class2)
class3 (d/entity db :user.class/Class3)]
(outliner-property/set-block-property! conn (:db/id class1) :logseq.property.class/extends (:db/id class2))
(outliner-property/set-block-property! conn (:db/id class2) :logseq.property.class/extends (:db/id class3))
(is (thrown-with-msg?
js/Error
#"Extends cycle"
(outliner-property/set-block-property! conn (:db/id class3) :logseq.property.class/extends (:db/id class1)))
"Extends cycle"))))
(deftest extends-redundant-direct-parent-cleanup
(testing "Clean redundant direct parents from descendants when a class gets a new parent"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:A :C]}}})
b (d/entity @conn :user.class/B)]
(outliner-property/set-block-property! conn
(:db/id b)
:logseq.property.class/extends
(:db/id (d/entity @conn :user.class/A)))
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D keeps only the nearest direct parent"))))
(deftest extends-redundant-cleanup-with-lookup-ref-parent
(testing "Treat lookup refs as a single parent reference"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:A :C]}}})]
(outliner-property/set-block-property! conn
(:db/id (d/entity @conn :user.class/B))
:logseq.property.class/extends
[:db/ident :user.class/A])
(is (= [:user.class/A]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/B))))
"B uses the lookup ref as one parent")
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct parent inherited through C"))))
(deftest extends-redundant-cleanup-with-keyword-vector-parents
(testing "Treat a vector of class idents as multiple parents"
(let [conn (db-test/create-conn-with-blocks
{:classes {:A {}
:B {}
:C {:build/class-extends [:A]}
:D {:build/class-extends [:A :B]}}})]
(outliner-property/batch-set-property! conn
[(:db/id (d/entity @conn :user.class/B))]
:logseq.property.class/extends
[:user.class/A :user.class/C])
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/B))))
"B keeps only the nearest parent")
(is (= [:user.class/B]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct parent inherited through B"))))
(deftest extends-redundant-direct-parent-cleanup-for-root-reset
(testing "Clean descendant Root parents when an ancestor is reset to Root"
(let [conn (db-test/create-conn-with-blocks
{:classes {:B {}
:C {:build/class-extends [:B]}
:D {:build/class-extends [:logseq.class/Root :C]}}})]
(outliner-property/set-block-property! conn
(:db/id (d/entity @conn :user.class/B))
:logseq.property.class/extends
:logseq.class/Root)
(is (= [:user.class/C]
(:logseq.property.class/extends
(db-test/readable-properties (d/entity @conn :user.class/D))))
"D removes the direct Root parent inherited through C"))))
(deftest delete-property-value!
(let [conn (db-test/create-conn-with-blocks
{:classes {:C1 {}
:C2 {}
:C3 {:build/class-extends [:C1 :C2]}}})]
(outliner-property/delete-property-value! conn :user.class/C3 :logseq.property.class/extends
(:db/id (d/entity @conn :user.class/C2)))
(is (= [:user.class/C1]
(:logseq.property.class/extends (db-test/readable-properties (d/entity @conn :user.class/C3))))
"Specific property value is deleted")
(outliner-property/delete-property-value! conn :user.class/C3 :logseq.property.class/extends
(:db/id (d/entity @conn :user.class/C1)))
(is (= [:logseq.class/Root]
(:logseq.property.class/extends (db-test/readable-properties (d/entity @conn :user.class/C3))))
"Extends property is restored back to Root")))

View File

@@ -0,0 +1,214 @@
(ns logseq.outliner.recycle-test
(:require [cljs.test :refer [deftest is]]
[datascript.core :as d]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[logseq.db.test.helper :as db-test]
[logseq.outliner.op :as outliner-op]
[logseq.outliner.recycle :as recycle]))
(deftest restore-recycled-page-removes-recycle-parent
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")]
(recycle/recycle-page-tx-data @conn page {})
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(recycle/restore! conn (:block/uuid page))
(let [page' (ldb/get-page @conn "page1")]
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest apply-ops-restore-recycled-page-removes-recycle-parent
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
page-uuid (:block/uuid page)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(outliner-op/apply-ops! conn [[:restore-recycled [page-uuid]]] {})
(let [page' (ldb/get-page @conn "page1")]
(is (nil? (:block/parent page')))
(is (nil? (:logseq.property/deleted-at page')))
(is (nil? (:logseq.property.recycle/original-parent page'))))))
(deftest permanently-delete-recycled-page-removes-page-and-descendants
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
block (db-test/find-block-by-content @conn "b1")
page-uuid (:block/uuid page)
block-uuid (:block/uuid block)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(is (true? (recycle/permanently-delete! conn page-uuid)))
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-page-removes-blocks-parented-by-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "page2"}}])
page1 (ldb/get-page @conn "page1")
page2 (ldb/get-page @conn "page2")
block-uuid (random-uuid)
now (common-util/time-ms)]
(d/transact! conn [{:block/uuid block-uuid
:block/title "parented by page1"
:block/created-at now
:block/updated-at now
:block/parent (:db/id page1)
:block/page (:db/id page2)
:block/order "a0"}])
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page1 {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn (:db/id page1)))))
(is (true? (recycle/permanently-delete! conn (:block/uuid page1))))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-converted-page-removes-property-value-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "outer"}
:blocks [{:block/title "target"
:build/properties {:default "value"}}]}])
target (db-test/find-block-by-content @conn "target")
target-id (:db/id target)
target-uuid (:block/uuid target)
value-id (d/q '[:find ?value .
:in $ ?target
:where
[?value :block/parent ?target]
[?value :logseq.property/created-from-property]]
@conn target-id)]
(d/transact! conn [[:db/retract target-id :block/page]
[:db/add target-id :block/name "target"]
[:db/add target-id :block/tags :logseq.class/Page]])
(let [page (d/entity @conn target-id)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page}))
(is (true? (recycle/permanently-delete! conn target-uuid)))
(is (nil? (d/entity @conn target-id)))
(is (nil? (d/entity @conn value-id)))))
(deftest gc-recycled-converted-page-removes-property-value-blocks
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "outer"}
:blocks [{:block/title "target"
:build/properties {:default "value"}}
{:block/title "unrelated"}]}])
outer (ldb/get-page @conn "outer")
target (db-test/find-block-by-content @conn "target")
unrelated (db-test/find-block-by-content @conn "unrelated")
target-id (:db/id target)
value-id (d/q '[:find ?value .
:in $ ?target
:where
[?value :block/parent ?target]
[?value :logseq.property/created-from-property]]
@conn target-id)]
(d/transact! conn [[:db/retract target-id :block/page]
[:db/add target-id :block/name "target"]
[:db/add target-id :block/tags :logseq.class/Page]])
(let [page (d/entity @conn target-id)]
(ldb/transact! conn
(recycle/recycle-page-tx-data @conn page {:now-ms 0})
{:outliner-op :delete-page}))
(is (true? (recycle/gc! conn {:now-ms (* 31 24 3600 1000)})))
(is (nil? (d/entity @conn target-id)))
(is (nil? (d/entity @conn value-id)))
(is (some? (d/entity @conn (:db/id outer))))
(is (some? (d/entity @conn (:db/id unrelated))))))
(deftest gc-keeps-unexpired-recycled-page
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}])
page (ldb/get-page @conn "page1")
page-id (:db/id page)]
(ldb/transact! conn
(recycle/recycle-page-tx-data @conn page {:now-ms 0})
{:outliner-op :delete-page})
(is (nil? (recycle/gc! conn {:now-ms (* 29 24 3600 1000)})))
(is (some? (d/entity @conn page-id)))))
(deftest apply-ops-permanently-delete-recycled-page-removes-page-and-descendants
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "b1"}]}])
page (ldb/get-page @conn "page1")
block (db-test/find-block-by-content @conn "b1")
page-uuid (:block/uuid page)
block-uuid (:block/uuid block)]
(ldb/transact! conn (recycle/recycle-page-tx-data @conn page {}) {:outliner-op :delete-page})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid page-uuid]))))
(outliner-op/apply-ops! conn [[:recycle-delete-permanently [page-uuid]]] {})
(is (nil? (d/entity @conn [:block/uuid page-uuid])))
(is (nil? (d/entity @conn [:block/uuid block-uuid])))))
(deftest permanently-delete-recycled-block-removes-subtree-only
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
page (ldb/get-page @conn "page1")
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
(is (true? (recycle/permanently-delete! conn parent-uuid)))
(is (some? (d/entity @conn [:block/uuid (:block/uuid page)])))
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest apply-ops-permanently-delete-recycled-block-removes-subtree-only
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "parent"
:build/children [{:block/title "child"}]}]}])
parent (db-test/find-block-by-content @conn "parent")
child (db-test/find-block-by-content @conn "child")
parent-uuid (:block/uuid parent)
child-uuid (:block/uuid child)]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [parent] {}) {:outliner-op :delete-blocks})
(is (true? (ldb/recycled? (d/entity @conn [:block/uuid parent-uuid]))))
(outliner-op/apply-ops! conn [[:recycle-delete-permanently [parent-uuid]]] {})
(is (nil? (d/entity @conn [:block/uuid parent-uuid])))
(is (nil? (d/entity @conn [:block/uuid child-uuid])))))
(deftest permanently-delete-recycled-block-removes-corresponding-view-history
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "target"}]}])
target (db-test/find-block-by-content @conn "target")
target-uuid (:block/uuid target)
view-uuid (random-uuid)
target-history-uuid (random-uuid)
view-history-uuid (random-uuid)
now (common-util/time-ms)
_ (d/transact! conn [{:block/uuid view-uuid
:block/title "target view"
:block/created-at now
:block/updated-at now
:logseq.property/view-for (:db/id target)
:logseq.property.view/type :logseq.property.view/type.table
:logseq.property.view/feature-type :linked-references}
{:block/uuid target-history-uuid
:block/created-at now
:block/updated-at now
:logseq.property.history/block (:db/id target)
:logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status))
:logseq.property.history/scalar-value "Todo"}
{:block/uuid view-history-uuid
:block/created-at now
:block/updated-at now
:logseq.property.history/block [:block/uuid view-uuid]
:logseq.property.history/property (:db/id (d/entity @conn :logseq.property/status))
:logseq.property.history/scalar-value "List"}])]
(ldb/transact! conn (recycle/recycle-blocks-tx-data @conn [target] {}) {:outliner-op :delete-blocks})
(is (true? (recycle/permanently-delete! conn target-uuid)))
(is (nil? (d/entity @conn [:block/uuid target-uuid])))
(is (nil? (d/entity @conn [:block/uuid view-uuid])))
(is (nil? (d/entity @conn [:block/uuid target-history-uuid])))
(is (nil? (d/entity @conn [:block/uuid view-history-uuid])))))

View File

@@ -0,0 +1,18 @@
(ns logseq.outliner.tree-test
(:require [cljs.test :refer [deftest is]]
[logseq.outliner.tree :as otree]))
(deftest blocks->vec-tree-data-preserves-caller-field-policy
(let [root {:db/id 1}
child {:db/id 2
:block/uuid (random-uuid)
:block/order "a0"
:block/parent {:db/id 1}
:block/tx-id 9}
default-result (otree/blocks->vec-tree-data [child] root {:include-root? false})
renderer-result (otree/blocks->vec-tree-data [child] root {:include-root? false
:keep-block-tx-id? true})]
(is (vector? default-result))
(is (= [1] (mapv :block/level default-result)))
(is (nil? (:block/tx-id (first default-result))))
(is (= 9 (:block/tx-id (first renderer-result))))))

View File

@@ -0,0 +1,308 @@
(ns logseq.outliner.validate-test
(:require [cljs.test :refer [are deftest is testing]]
[datascript.core :as d]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.test.helper :as db-test]
[logseq.outliner.validate :as outliner-validate]))
(deftest validate-block-title-unique-for-properties
(let [conn (db-test/create-conn-with-blocks
{:properties {:color {:logseq.property/type :default}
:color2 {:logseq.property/type :default}}})]
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
(:block/title (d/entity @conn :logseq.property/background-color))
(d/entity @conn :user.property/color)))
"Allow user property to have same name as built-in property")
(is (thrown-with-msg?
js/Error
#"Duplicate property"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"color"
(d/entity @conn :user.property/color2)))
"Disallow duplicate user property")))
(deftest validate-block-title-unique-for-tags
(let [conn (db-test/create-conn-with-blocks
{:classes {:Class1 {}
:Class2 {:logseq.property.class/extends :logseq.class/Task}}})]
(is (thrown-with-msg?
js/Error
#"Duplicate class"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Class1"
(d/entity @conn :user.class/Class2)))
"Disallow duplicate class names, regardless of extends")
(is (thrown-with-msg?
js/Error
#"Duplicate class"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Card"
(d/entity @conn :user.class/Class1)))
"Disallow duplicate class names even if it's built-in")))
(deftest validate-block-title-unique-for-namespaced-pages
(let [conn (db-test/create-conn-with-blocks
{:pages-and-blocks
[{:page {:block/title "Library"
:block/uuid #uuid "d246c71a-3e71-42f0-928f-afe607ee5ce0"
:build/keep-uuid? true
:build/properties {:logseq.property/built-in? true}}}
{:page {:block/title "n1"
:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"
:build/keep-uuid? true
:block/parent [:block/uuid #uuid "d246c71a-3e71-42f0-928f-afe607ee5ce0"]}}
{:page {:block/title "n2"
:block/parent [:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"]}}
{:page {:block/title "n3"
:block/parent [:block/uuid #uuid "3aa1e950-5a9b-4efc-81d4-b6d89a504591"]}}]
:build-existing-tx? true})]
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"n2"
(db-test/find-page-by-title @conn "n3")))
"Disallow duplicate namespace child")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"n4"
(db-test/find-page-by-title @conn "n3")))
"Allow namespace child if unique")))
(deftest validate-block-title-unique-for-pages
(let [conn (db-test/create-conn-with-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "another page"}}
{:page {:block/title "Apple" :build/tags [:Company]}}
{:page {:block/title "Another Company" :build/tags [:Company]}}
{:page {:block/title "Banana" :build/tags [:Fruit]}}])]
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Another Company")))
"Disallow duplicate page with tag")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Banana")))
"Allow page with same name for different tag")
(is (thrown-with-msg?
js/Error
#"Duplicate page"
(outliner-validate/validate-unique-by-name-and-tags
@conn
"page1"
(db-test/find-page-by-title @conn "another page")))
"Disallow duplicate page without tag")
(is (nil?
(outliner-validate/validate-unique-by-name-and-tags
@conn
"Apple"
(db-test/find-page-by-title @conn "Fruit")))
"Allow class to have same name as a page")))
(deftest validate-extends-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:prop1 {:logseq.property/type :default}}
:classes {:Class1 {} :Class2 {}}
:pages-and-blocks
[{:page {:block/title "page1"}}
{:page {:block/title "page2"}}]})
page1 (db-test/find-page-by-title @conn "page1")
class1 (db-test/find-page-by-title @conn "Class1")
class2 (db-test/find-page-by-title @conn "Class2")
property (db-test/find-page-by-title @conn "prop1")
db @conn]
(testing "valid parent and child combinations"
(is (nil? (outliner-validate/validate-extends-property db class1 [class2]))
"parent class to child class is valid"))
(testing "invalid parent and child combinations"
(are [parent child]
(thrown-with-msg?
js/Error
#"Can't extend"
(outliner-validate/validate-extends-property db parent [child]))
class1 page1
page1 class1
property class1))
(testing "built-in tag can't have parent changed"
(is (thrown-with-msg?
js/Error
#"Can't change.*built-in"
(outliner-validate/validate-extends-property db
(entity-plus/entity-memoized @conn :logseq.class/Task)
[(entity-plus/entity-memoized @conn :logseq.class/Cards)]))))))
(deftest validate-tags-property
(let [class-uuid (random-uuid)
conn (db-test/create-conn-with-blocks
{:classes {:SomeTag {:block/uuid class-uuid :build/keep-uuid? true}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "block"
:build/children [{:block/title "block - invalid location"}]}
{:block/title "block / invalid title"}]}
{:page {:block/uuid class-uuid}
:blocks [{:block/title "class block"}]}]
:build-existing-tx? true})
block (db-test/find-block-by-content @conn "block")
block-invalid-title (db-test/find-block-by-content @conn #"invalid title")
block-invalid-location (db-test/find-block-by-content @conn #"invalid location")]
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Tag"
(outliner-validate/validate-tags-property @conn [:logseq.class/Tag] :user.class/SomeTag))
"built-in tag must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Heading"
(outliner-validate/validate-tags-property @conn [:logseq.property/heading] :user.class/SomeTag))
"built-in property must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't add tag.*Contents"
(outliner-validate/validate-tags-property @conn [(:db/id (db-test/find-page-by-title @conn "Contents"))] :user.class/SomeTag))
"built-in page must not be tagged by the user")
(is (thrown-with-msg?
js/Error
#"Can't set tag.*Tag"
(outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.class/Tag))
"Nodes can't be tagged with built-in private tags")
(is (thrown-with-msg?
js/Error
#"Can't set tag.*Priority"
(outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.property/priority))
"Nodes can't be tagged with built-in non tags")
(is (nil? (outliner-validate/validate-tags-property @conn [(:db/id block)] :logseq.class/Page))
"Blocks can be tagged with #Page")
(is (thrown-with-msg?
js/Error
#"Page name can't.*/"
(outliner-validate/validate-tags-property @conn [(:db/id block-invalid-title)] :logseq.class/Page))
"Block with invalid title can't be tagged with #Page")
(is (thrown-with-msg?
js/Error
#"Can't convert this block to page"
(outliner-validate/validate-tags-property @conn [(:db/id block-invalid-location)] :logseq.class/Page))
"Block with invalid location can't be tagged with #Page")))
(deftest validate-tags-property-deletion
(let [conn (db-test/create-conn-with-blocks
{:classes {:SomeTag {}}
:pages-and-blocks
[{:page {:block/title "page1"}
:blocks [{:block/title "block" :build/tags [:logseq.class/Page]}]}]})
page (db-test/find-page-by-title @conn "page1")
page-with-parent (db-test/find-block-by-content @conn "block")]
(is (thrown-with-msg?
js/Error
#"Can't remove tag.*Task"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id (d/entity @conn :logseq.class/Task))] :logseq.class/Tag))
"built-in class must not have tag deleted by the user")
(is (thrown-with-msg?
js/Error
#"Can't remove tag.*Tag"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id (d/entity @conn :user.class/SomeTag))] :logseq.class/Tag))
"Node can't have private tag deleted by user")
(is (nil? (outliner-validate/validate-tags-property-deletion @conn [(:db/id page-with-parent)] :logseq.class/Page))
"Page with parent can remove #Page")
(is (thrown-with-msg?
js/Error
#"This page cannot be converted"
(outliner-validate/validate-tags-property-deletion @conn [(:db/id page)] :logseq.class/Page))
"Page without parent can't remove #Page")))
(deftest validate-editing-built-in-property
(let [conn (db-test/create-conn-with-blocks
{:properties {:myprop {:logseq.property/type :default}}})
user-prop (d/entity @conn :user.property/myprop)
built-in-prop (d/entity @conn :block/tags)]
(testing "user property can edit any attribute"
(is (nil? (outliner-validate/validate-editing-built-in-property
user-prop {:db/cardinality :db.cardinality/many}))))
(testing "built-in property cannot edit disallowed attribute"
(is (thrown-with-msg?
js/Error
#"not editable"
(outliner-validate/validate-editing-built-in-property
built-in-prop {:block/title "renamed"}))))
(testing "built-in property can edit allowed attribute"
(is (nil? (outliner-validate/validate-editing-built-in-property
built-in-prop {:logseq.property/hide-empty-value true}))))))
;; Try as many of the validations against a new graph to confirm
;; that validations make sense and are valid for a new graph
(deftest new-graph-should-be-valid
(let [conn (db-test/create-conn)]
(testing "Validate pages"
(let [pages (->> (d/q '[:find [?b ...] :where
[?b :block/title]
[?b :block/tags]] @conn)
(map (fn [id]
(d/entity @conn id))))
page-errors (atom {})]
(doseq [page pages]
(try
(outliner-validate/validate-unique-by-name-and-tags @conn (:block/title page) page)
(outliner-validate/validate-page-title (:block/title page) {:node page})
(outliner-validate/validate-page-title-characters (:block/title page) {:node page})
(when (entity-util/property? page) (outliner-validate/validate-property-title (:block/title page)))
(when (entity-util/class? page)
(doseq [parent (:logseq.property.class/extends page)]
(outliner-validate/validate-extends-property @conn parent [page] {:built-in? false})))
(catch :default e
(if (= :notification (:type (ex-data e)))
(swap! page-errors update (select-keys page [:block/title :db/ident :block/uuid]) (fnil conj []) e)
(throw e)))))
(is (= {} @page-errors)
"Default pages shouldn't have any validation errors")))
(testing "Validate property relationships"
(let [parent-child-pairs (d/q '[:find ?parent ?child
:where [?child :logseq.property.class/extends ?parent]] @conn)]
(doseq [[parent-id child-id] parent-child-pairs]
(let [parent (d/entity @conn parent-id)
child (d/entity @conn child-id)]
(is (nil? (#'outliner-validate/validate-extends-property-have-correct-type parent [child]))
(str "Parent and child page is valid: " (pr-str (:block/title parent)) " " (pr-str (:block/title child))))))))))